commit a35818e359c8b338210e314e70a4d051d9a2d2bd Author: felix Date: Sat Oct 18 10:54:08 2025 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..4f76b0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +.idea/ +backend/.env +.venv/ +venv/ +backend/alembic/versions/ +*.log +.ruff_cache/ +backend/static \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100755 index 0000000..a0a67bf --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.11.4 + hooks: + - id: ruff + args: + - '--config' + - '.ruff.toml' + - '--fix' + - '--unsafe-fixes' + - id: ruff-format + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.6.14 + hooks: + - id: uv-lock + - id: uv-export + args: + - '-o' + - 'requirements.txt' + - '--no-hashes' diff --git a/.ruff.toml b/.ruff.toml new file mode 100755 index 0000000..c0accaf --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,35 @@ +line-length = 120 +unsafe-fixes = true +cache-dir = ".ruff_cache" +target-version = "py310" + +[lint] +select = [ + "E", + "F", + "W505", + "SIM101", + "SIM114", + "PGH004", + "PLE1142", + "RUF100", + "I002", + "F404", + "TC", + "UP007" +] +preview = true + +[lint.isort] +lines-between-types = 1 +order-by-type = true + +[lint.per-file-ignores] +"**/api/v1/*.py" = ["TC"] +"**/model/*.py" = ["TC003"] +"**/model/__init__.py" = ["F401"] + +[format] +preview = true +quote-style = "single" +docstring-code-format = true diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..47b8e43 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.10-slim + +WORKDIR /fsm + +COPY . . + +RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources \ + && sed -i 's|security.debian.org/debian-security|mirrors.ustc.edu.cn/debian-security|g' /etc/apt/sources.list.d/debian.sources + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc python3-dev supervisor \ + && rm -rf /var/lib/apt/lists/* \ + # 某些包可能存在同步不及时导致安装失败的情况,可更改为官方源:https://pypi.org/simple + && pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple \ + && pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple \ + && pip install gunicorn wait-for-it -i https://mirrors.aliyun.com/pypi/simple + +ENV TZ="Asia/Shanghai" + +RUN mkdir -p /var/log/fastapi_server \ + && mkdir -p /var/log/supervisor \ + && mkdir -p /etc/supervisor/conf.d + +COPY deploy/supervisor.conf /etc/supervisor/supervisord.conf + +COPY deploy/fastapi_server.conf /etc/supervisor/conf.d/ + +EXPOSE 8001 + +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/assets/dict/dictionary_parser.py b/assets/dict/dictionary_parser.py new file mode 100755 index 0000000..69c619f --- /dev/null +++ b/assets/dict/dictionary_parser.py @@ -0,0 +1,1144 @@ +import base64 +import os +import re +import psycopg2 +import hashlib +from typing import List, Tuple, Dict, Optional, Any +from readmdict import MDX, MDD +from bs4 import BeautifulSoup, Tag +import json + +from backend.app.admin.schema.dict import Example, Frequency, Pronunciation, FamilyItem, WordFamily, \ + WordMetaData, Sense, Definition, Topic, CrossReference, DictEntry, Etymology, EtymologyItem + + +class DictionaryParser: + def __init__(self, db_config: Dict): + """初始化数据库连接""" + self.db_config = db_config + self.conn = None + self.connect_db() + + def connect_db(self): + """连接到PostgreSQL数据库""" + try: + self.conn = psycopg2.connect(**self.db_config) + except Exception as e: + print(f"数据库连接失败: {e}") + raise + + def parse_mdx_mdd(self, mdx_path: str, mdd_path: str = None) -> None: + """解析MDX和MDD文件""" + try: + # 解析MDX文件 + entries, media_references = self.parse_mdx_file_mdict(mdx_path) + + # 保存词汇条目 + entry_ids = self.save_entries(entries) + + # 如果有MDD文件,解析媒体文件 + if mdd_path and os.path.exists(mdd_path): + self.parse_mdd_file(mdd_path, media_references, entry_ids) + else: + print("未提供MDD文件或文件不存在") + + print(f"解析完成,共处理 {len(entries)} 个词汇条目") + + except Exception as e: + print(f"解析词典文件失败: {e}") + raise + + def parse_mdx_file_mdict(self, mdx_path: str) -> Tuple[List[Tuple[str, str]], List[Dict]]: + """使用 mdict_reader 解析 MDX 文件""" + print(f"正在解析MDX文件: {mdx_path}") + + try: + mdx = MDX(mdx_path) + entries = [] + media_references = [] + + for key, value in mdx.items(): + word = key.decode('utf-8') if isinstance(key, bytes) else str(key) + definition = value.decode('utf-8') if isinstance(value, bytes) else str(value) + + if word and definition: + entries.append((word, definition)) + # 提取媒体文件引用 + media_refs = self.extract_media_references(definition, word) + media_references.extend(media_refs) + + return entries, media_references + + except Exception as e: + print(f"解析MDX文件失败: {e}") + raise + + def parse_mdd_file(self, mdd_path: str, media_references: List[Dict], entry_ids: Dict[str, int]) -> None: + """解析MDD文件中的媒体资源 - 使用 mdict_reader""" + print(f"正在解析MDD文件: {mdd_path}") + + try: + # 使用 mdict_reader 解析 MDD 文件 + mdd = MDD(mdd_path) + + # 创建文件名到媒体数据的映射 + dict_media = {} + for key, value in mdd.items(): + filename = key.decode('utf-8') if isinstance(key, bytes) else str(key) + # 确保文件名格式统一 + filename = filename.replace('\\', '/').lstrip('/') + dict_media[filename] = value + + # 保存媒体文件 + self.save_dict_media(dict_media, media_references, entry_ids) + + except Exception as e: + print(f"解析MDD文件失败: {e}") + raise + + def extract_media_references(self, definition: str, word: str) -> List[Dict]: + """从定义中提取媒体文件引用""" + media_refs = [] + + # 提取音频文件引用 - 更通用的模式,匹配 sound:// 或 href="sound://..." + # 这个模式应该能覆盖 aeroplane.txt 中的 sound://media/english/... 链接 + audio_patterns = [ + r'sound://([^"\s>]+\.mp3)', # 直接 sound:// 开头,后跟非空格/"/>字符直到 .mp3 + r'href\s*=\s*["\']sound://([^"\'>]+\.mp3)["\']', # href="sound://..." + r'href\s*=\s*["\']sound://([^"\'>]+)["\']', # 更宽松的 href="sound://...",不一定以.mp3结尾 + r'data-src-mp3\s*=\s*["\']sound://([^"\'>]+\.mp3)["\']', # data-src-mp3="sound://..." + r'data-src-mp3\s*=\s*["\']([^"\'>]+\.mp3)["\']', # data-src-mp3="..." (相对路径) + r'audio\s*=\s*["\']([^"\']+)["\']', # audio="..." + ] + + for pattern in audio_patterns: + matches = re.findall(pattern, definition, re.IGNORECASE) + for match in matches: + # 清理可能的多余字符(如结尾的引号或空格,虽然正则应该已经避免了) + clean_filename = match.strip()#.rstrip('"\'') + if clean_filename: + media_refs.append({ + 'filename': clean_filename, + 'type': 'audio', + 'word': word + }) + + # 提取图片文件引用 + image_patterns = [ + r']*src\s*=\s*["\']([^"\']+\.(?:jpg|jpeg|png|gif|bmp))["\']', # src="..." + r'\[image:([^\]]+\.(?:jpg|jpeg|png|gif|bmp))\]', # [image:...] + r'src\s*=\s*["\']([^"\']+\.(?:jpg|jpeg|png|gif|bmp))["\']' # 更宽松的 src="..." + ] + + for pattern in image_patterns: + matches = re.findall(pattern, definition, re.IGNORECASE) + for match in matches: + # 清理可能的多余字符 + clean_filename = match.strip()#.rstrip('"\'') + if clean_filename: + media_refs.append({ + 'filename': clean_filename, + 'type': 'image', + 'word': word + }) + + return media_refs + + def save_entries(self, entries: List[Tuple[str, str]]) -> Dict[str, int]: + """保存词汇条目到数据库,并更新 details 字段""" + from psycopg2.extras import Json + import hashlib + + cursor = self.conn.cursor() + entry_ids = {} + + for word, definition in entries: + try: + # 检查数据库中是否已存在该词条 + cursor.execute('SELECT id, definition, details FROM dict_entry WHERE word = %s', (word,)) + existing_record = cursor.fetchone() + + metadata = None + existing_details = None + final_definition = definition # 默认使用当前 definition + + # 如果存在现有记录 + if existing_record: + entry_id, existing_definition, existing_details_json = existing_record + + # 获取现有的 details + if existing_details_json: + try: + existing_details = WordMetaData(**existing_details_json) + except: + existing_details = None + + # 如果当前 definition 是以 @@@ 开头的引用链接 + if definition.startswith('@@@'): + # 保留现有的 definition,只更新 details 中的 ref_link + final_definition = existing_definition # 保持原有的 definition + + # 提取新的 @@@ 链接 + lines = definition.split('\n') + new_ref_links = [] + for line in lines: + if line.startswith('@@@'): + link = line[3:].strip() + if link: + new_ref_links.append(link) + else: + break + + # 合并链接信息 + if new_ref_links: + if existing_details: + # 如果已有 details,合并 ref_link + if existing_details.ref_link: + # 合并现有链接和新链接,去重但保持顺序 + combined_links = existing_details.ref_link[:] + for link in new_ref_links: + if link not in combined_links: + combined_links.append(link) + existing_details.ref_link = combined_links + else: + existing_details.ref_link = new_ref_links + metadata = existing_details + else: + # 如果没有现有 details,创建新的 metadata + metadata = WordMetaData() + metadata.ref_link = new_ref_links + + # 保留现有的 metadata + elif existing_details: + metadata = existing_details + else: + # 如果当前 definition 不是 @@@ 开头,则正常更新 definition 和解析 HTML + final_definition = definition + + # 解析 HTML 内容获取 metadata 信息 + html_metadata, images_info1 = self.parse_definition_to_metadata(definition) + if images_info1: + self.save_entry_images(entry_id, word, images_info1) + + # 合并 metadata 信息 + if html_metadata: + if existing_details: + # 保留现有的 ref_link,合并其他字段 + html_metadata.ref_link = existing_details.ref_link + metadata = html_metadata + + # 提取并处理图片信息 + images_info = self.extract_images_from_definition(definition, word) + if images_info: + self.save_entry_images(entry_id, word, images_info) + else: + # 新词条,正常处理 + if definition.startswith('@@@'): + # 处理 @@@ 开头的引用链接 + lines = definition.split('\n') + ref_links = [] + for line in lines: + if line.startswith('@@@'): + link = line[3:].strip() + if link: + ref_links.append(link) + else: + break + + if ref_links: + metadata = WordMetaData() + metadata.ref_link = ref_links + else: + # 解析 HTML 内容 + html_metadata, images_info1 = self.parse_definition_to_metadata(definition) + metadata = html_metadata + + # 提取并处理图片信息 + images_info = self.extract_images_from_definition(definition, word) + if images_info or images_info1: + # 先插入词条获取 entry_id + cursor.execute(''' + INSERT INTO dict_entry (word, definition, details) + VALUES (%s, %s, %s) RETURNING id + ''', (word, definition, Json(metadata.model_dump()) if metadata else None)) + + entry_id = cursor.fetchone()[0] + entry_ids[word] = entry_id + + # 处理图片信息 + if images_info: + self.save_entry_images(entry_id, word, images_info) + if images_info1: + self.save_entry_images(entry_id, word, images_info1) + continue # 跳过后续的插入操作 + + # 保存或更新词条到数据库 + if existing_record: + # 更新现有记录 + cursor.execute(''' + UPDATE dict_entry + SET definition = %s, + details = %s + WHERE word = %s RETURNING id + ''', (final_definition, Json(metadata.model_dump()) if metadata else None, word)) + entry_id = cursor.fetchone()[0] if cursor.rowcount > 0 else existing_record[0] + entry_ids[word] = entry_id + else: + # 插入新记录(仅当不是上面处理过的情况) + if word not in entry_ids: # 避免重复插入 + cursor.execute(''' + INSERT INTO dict_entry (word, definition, details) + VALUES (%s, %s, %s) RETURNING id + ''', (word, final_definition, Json(metadata.model_dump()) if metadata else None)) + result = cursor.fetchone() + if result: + entry_ids[word] = result[0] + + except Exception as e: + print(f"保存词汇 '{word}' 时出错: {e}") + continue + + self.conn.commit() + cursor.close() + return entry_ids + + def save_dict_media(self, media_files: Dict[str, bytes], media_references: List[Dict], + entry_ids: Dict[str, int]) -> None: + """保存媒体文件到数据库""" + # 按文件名分组媒体引用 + refs_by_filename = {} + for ref in media_references: + filename = ref['filename'].replace('\\', '/').lstrip('/') + if filename not in refs_by_filename: + refs_by_filename[filename] = [] + refs_by_filename[filename].append(ref) + + saved_count = 0 + error_count = 0 + + for filename, file_data in media_files.items(): + if filename in refs_by_filename: + try: + # 每次操作都使用新的游标 + cursor = self.conn.cursor() + + # 计算文件哈希 + file_hash = hashlib.sha256(file_data).hexdigest() + + # 先检查是否已存在 + cursor.execute(''' + SELECT COUNT(*) + FROM dict_media + WHERE file_name = %s + ''', (filename,)) + + if cursor.fetchone()[0] > 0: + print(f"文件已存在,跳过: {filename}") + cursor.close() + continue + + file_type = refs_by_filename[filename][0]['type'] + # 保存文件数据 + cursor.execute(''' + INSERT INTO dict_media (file_name, file_type, file_data, file_hash) + VALUES (%s, %s, %s, %s) RETURNING id + ''', (filename, file_type, psycopg2.Binary(file_data), file_hash)) + + media_id = cursor.fetchone()[0] + + # 关联到对应的词汇条目 + update_count = 0 + for ref in refs_by_filename[filename]: + word = ref['word'] + if word in entry_ids: + cursor.execute(''' + UPDATE dict_media + SET dict_id = %s + WHERE id = %s + ''', (entry_ids[word], media_id)) + update_count += 1 + + self.conn.commit() + cursor.close() + + saved_count += 1 + if saved_count % 100 == 0: + print(f"已处理 {saved_count} 个媒体文件") + + except Exception as e: + # 发生错误时回滚并继续处理下一个文件 + try: + self.conn.rollback() + cursor.close() + except: + pass + error_count += 1 + print(f"保存媒体文件 '{filename}' 时出错: {e}") + continue + else: + # 处理图片文件(没有在 media_references 中的文件) + try: + cursor = self.conn.cursor() + + # 计算文件哈希 + file_hash = hashlib.sha256(file_data).hexdigest() + + # 检查是否已存在 + cursor.execute(''' + SELECT COUNT(*) + FROM dict_media + WHERE file_name = %s + ''', (filename,)) + + if cursor.fetchone()[0] == 0: + # 保存图片文件数据 + cursor.execute(''' + INSERT INTO dict_media (file_name, file_type, file_data, file_hash) + VALUES (%s, %s, %s, %s) + ''', (filename, 'image', psycopg2.Binary(file_data), file_hash)) + self.conn.commit() + + cursor.close() + saved_count += 1 + + except Exception as e: + try: + self.conn.rollback() + cursor.close() + except: + pass + error_count += 1 + print(f"保存图片文件 '{filename}' 时出错: {e}") + + print(f"媒体文件处理完成: 成功 {saved_count} 个,错误 {error_count} 个") + + def export_media_files(self, output_dir: str) -> None: + """导出媒体文件到指定目录""" + cursor = self.conn.cursor() + + cursor.execute(''' + SELECT id, file_name, file_type, file_data + FROM dict_media + WHERE file_data IS NOT NULL + ''') + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + audio_dir = os.path.join(output_dir, 'audio') + image_dir = os.path.join(output_dir, 'images') + + for dir_path in [audio_dir, image_dir]: + if not os.path.exists(dir_path): + os.makedirs(dir_path) + + count = 0 + for id, filename, file_type, file_data in cursor.fetchall(): + try: + if file_type == 'audio': + # 尝试从 filename 中提取扩展名,如果没有则默认 .mp3 + ext = os.path.splitext(filename)[1] + if not ext: + ext = '.mp3' + output_path = os.path.join(audio_dir, f"{id}{ext}") + else: + # 图片文件,保留原文件名 + safe_filename = os.path.basename(filename) + if not safe_filename: + safe_filename = f"{id}.jpg" # 默认图片扩展名 + output_path = os.path.join(image_dir, safe_filename) + + with open(output_path, 'wb') as f: + f.write(file_data) + count += 1 + + except Exception as e: + print(f"导出文件 '{filename}' 失败: {e}") + continue + + cursor.close() + print(f"成功导出 {count} 个媒体文件到 {output_dir}") + + def extract_images_from_definition(self, definition_html: str, word: str) -> List[Dict]: + """ + 从 definition HTML 中提取图片引用 + """ + + soup = BeautifulSoup(definition_html, 'html.parser') + images_refs = [] + + # 查找带有 picfile 属性的 span 标签 + ldoce_entry = soup.find('span', class_='ldoceEntry Entry') + if ldoce_entry: + picfile_spans = ldoce_entry.find_all('span', attrs={'picfile': True}) + for pic_span in picfile_spans: + img_tag = pic_span.find('img') + sense_id = pic_span.get('id') + if img_tag: + alt_attr = img_tag.get('alt') + src_attr = img_tag.get('src') + base64_attr = img_tag.get('base64') + if base64_attr: + # 检查是否是 base64 格式 + if base64_attr.startswith('data:image/'): + # 提取 base64 数据 + base64_data = base64_attr.split(',')[1] if ',' in base64_attr else base64_attr + try: + # 解码 base64 数据 + image_data = base64.b64decode(base64_data) + images_refs.append({ + 'sense_id': sense_id, + 'filename': alt_attr, + 'src': base64_attr, + 'image_data': image_data, # 实际的二进制图片数据 + 'type': 'image', + 'word': word + }) + except Exception as e: + print(f"解码 base64 图片数据失败: {e}") + # 如果解码失败,仍然记录基本信息 + images_refs.append({ + 'sense_id': sense_id, + 'filename': alt_attr, + 'src': src_attr, + 'type': 'image', + 'word': word + }) + else: + # 不是 base64 格式,可能是文件路径 + images_refs.append({ + 'sense_id': sense_id, + 'filename': alt_attr, + 'src': src_attr, + 'type': 'image', + 'word': word + }) + + return images_refs + + def parse_definition_to_metadata(self, definition_html: str) -> tuple[Optional[WordMetaData], List[Dict]]: + """ + 从 definition HTML 中提取 WordMetaData 信息,并处理图片信息 + 返回: (metadata, images_info_list) + """ + soup = BeautifulSoup(definition_html, 'html.parser') # 可改为 'lxml' if installed + images_info: List[Dict] = [] + word_metadata: Dict[str, Any] = {'dict_list': []} + + try: + # 查找所有 dictentry 容器 + dict_entries = soup.find_all('span', class_='dictentry') + if not dict_entries: + print(f"未找到 dictentry 节点") + return WordMetaData(**word_metadata), images_info + + for dict_entry in dict_entries: + entry: Dict[str, Any] = {} + # --- 1. 基本词条信息 --- + head_tag = dict_entry.find(class_='Head') + if head_tag: + # GRAM 及物性 + head_gram_tag = head_tag.find(class_='GRAM') + if head_gram_tag: + full_text = ''.join(head_gram_tag.stripped_strings) + match = re.search(r'\[([^\]]+)\]', full_text) + if match: + content = match.group(1) + entry['transitive'] = [item.strip().lower() for item in content.split(',')] + + hwd_tag = dict_entry.find(class_='HWD') + if hwd_tag: + entry['headword'] = hwd_tag.get_text(strip=True) + + # 同形异义词编号 HOMNUM + homnum_tag = dict_entry.find(class_='HOMNUM') + if homnum_tag: + try: + entry['homograph_number'] = int(homnum_tag.get_text(strip=True)) + except ValueError: + pass # Ignore if not a number + + # 词性 lm5pp_POS (取第一个) + pos_tag = dict_entry.find(class_='lm5pp_POS') + if pos_tag: + entry['part_of_speech'] = pos_tag.get_text(strip=True) + + # --- 2. 发音 Pronunciations --- + pron_dict = {} + # 英式发音 IPA + uk_pron_tag = dict_entry.find(class_='PRON') # 通常第一个是英式 + if uk_pron_tag: + # 处理 ə 这样的音标变体 + ipa_text = ''.join(uk_pron_tag.stripped_strings) + pron_dict['uk_ipa'] = ipa_text.strip('/ ') # 去掉斜杠 + + # 美式发音 IPA (可能在 AMEVARPRON 中) + us_pron_tag = dict_entry.find(class_='AMEVARPRON') + if us_pron_tag: + us_ipa_text = ''.join(us_pron_tag.stripped_strings) + pron_dict['us_ipa'] = us_ipa_text.strip('/ $ ') # 去掉斜杠和美元符号 + + # 英式音频 - 优先查找 data-src-mp3,然后查找 href="sound://..." + uk_audio_tag = dict_entry.find('a', class_='speaker brefile', attrs={'data-src-mp3': lambda x: x and x.startswith('sound://')}) + if not uk_audio_tag: + # 查找 href 属性以 sound:// 开头的 + uk_audio_tag = dict_entry.find('a', class_='speaker brefile', href=lambda x: x and x.startswith('sound://')) + if not uk_audio_tag: + # 更宽松的查找,只要 class 包含 speaker 和 brefile + uk_audio_tag = dict_entry.find('a', class_=lambda x: x and 'speaker' in x and 'brefile' in x, attrs={'data-src-mp3': True}) + if not uk_audio_tag: + uk_audio_tag = dict_entry.find('a', class_=lambda x: x and 'speaker' in x and 'brefile' in x, href=lambda x: x and x.startswith('sound://')) + + if uk_audio_tag: + # 优先使用 data-src-mp3 + uk_audio_src = uk_audio_tag.get('data-src-mp3') + if not uk_audio_src or not uk_audio_src.startswith('sound://'): + # 否则使用 href + uk_audio_href = uk_audio_tag.get('href', '') + if uk_audio_href.startswith('sound://'): + uk_audio_src = uk_audio_href + if uk_audio_src: + pron_dict['uk_audio'] = uk_audio_src.replace('sound://', '', 1) + pron_dict['uk_audio_title'] = uk_audio_tag.get('title', '') + + # 美式音频 - 优先查找 data-src-mp3,然后查找 href="sound://..." + us_audio_tag = dict_entry.find('a', class_='speaker amefile', attrs={'data-src-mp3': lambda x: x and x.startswith('sound://')}) + if not us_audio_tag: + us_audio_tag = dict_entry.find('a', class_='speaker amefile', href=lambda x: x and x.startswith('sound://')) + if not us_audio_tag: + us_audio_tag = dict_entry.find('a', class_=lambda x: x and 'speaker' in x and 'amefile' in x, attrs={'data-src-mp3': True}) + if not us_audio_tag: + us_audio_tag = dict_entry.find('a', class_=lambda x: x and 'speaker' in x and 'amefile' in x, href=lambda x: x and x.startswith('sound://')) + + if us_audio_tag: + us_audio_src = us_audio_tag.get('data-src-mp3') + if not us_audio_src or not us_audio_src.startswith('sound://'): + us_audio_href = us_audio_tag.get('href', '') + if us_audio_href.startswith('sound://'): + us_audio_src = us_audio_href + if us_audio_src: + pron_dict['us_audio'] = us_audio_src.replace('sound://', '', 1) + pron_dict['us_audio_title'] = us_audio_tag.get('title', '') + + if pron_dict: + entry['pronunciations'] = Pronunciation(**pron_dict) + + # --- 3. 频率 Frequency --- + freq_dict = {} + freq_level_tag = dict_entry.find(class_='LEVEL') + if freq_level_tag: + freq_dict['level'] = freq_level_tag.get('title', '').strip() + freq_dict['level_tag'] = freq_level_tag.get_text(strip=True) + + freq_spoken_tag = dict_entry.find(class_='FREQ', title=lambda x: x and 'spoken' in x.lower()) + if freq_spoken_tag: + freq_dict['spoken'] = freq_spoken_tag.get('title', '').strip() + freq_dict['spoken_tag'] = freq_spoken_tag.get_text(strip=True) + + freq_written_tag = dict_entry.find(class_='FREQ', title=lambda x: x and 'written' in x.lower()) + if freq_written_tag: + freq_dict['written'] = freq_written_tag.get('title', '').strip() + freq_dict['written_tag'] = freq_written_tag.get_text(strip=True) + + if freq_dict: + entry['frequency'] = Frequency(**freq_dict) + + # --- 4. 话题 Topics --- + topics_list = [] + topic_tags = dict_entry.find_all('a', class_='topic') + for topic_tag in topic_tags: + topic_text = topic_tag.get_text(strip=True) + topic_href = topic_tag.get('href', '') + if topic_text: + topics_list.append(Topic(name=topic_text, href=topic_href)) + if topics_list: + entry['topics'] = topics_list + + # --- 5. 词族 Word Family --- + word_fams_div = dict_entry.find(class_='LDOCE_word_family') + if word_fams_div: + families_list = [] + current_pos = None + current_items = [] + # 遍历子元素 + for child in word_fams_div.children: + if isinstance(child, Tag): + if 'pos' in child.get('class', []): + # 如果遇到新的 pos,先保存上一个 + if current_pos and current_items: + families_list.append(WordFamily(pos=current_pos, items=current_items)) + # 开始新的 pos 组 + current_pos = child.get_text(strip=True) + current_items = [] + elif 'w' in child.get('class', []): # 包括 'crossRef w' 和 'w' + item_text = child.get_text(strip=True) + item_href = child.get('href', '') if child.name == 'a' else None + current_items.append(FamilyItem(text=item_text, href=item_href)) + # 保存最后一个 pos 组 + if current_pos and current_items: + families_list.append(WordFamily(pos=current_pos, items=current_items)) + + if families_list: + entry['word_family'] = families_list + + # --- 6. 义项 Senses 和 定义/例子 --- + senses_list = [] + # 查找所有 Sense div (可能带有 newline 类) + sense_tags = dict_entry.find_all('span', class_=lambda x: x and 'Sense' in x) + for sense_tag in sense_tags: + if not isinstance(sense_tag, Tag): + continue + sense_id = sense_tag.get('id', '') + sense_dict: Dict[str, Any] = {'id': sense_id} + + # Sense 编号 (sensenum) + sensenum_tag = sense_tag.find(class_='sensenum') + if sensenum_tag: + sense_dict['number'] = sensenum_tag.get_text(strip=True) + + # GRAM 可数性 + gram_tag = sense_tag.find(class_='GRAM') + if gram_tag: + full_text = ''.join(gram_tag.stripped_strings) + # 使用正则表达式匹配方括号内的内容,例如 [countable, uncountable] + match = re.search(r'\[([^\]]+)\]', full_text) + if match: + # 提取方括号内的文本,如 "countable, uncountable" + content = match.group(1) + # 按逗号分割,并清理每个词 + sense_dict['countability'] = [item.strip().lower() for item in content.split(',')] + + # --- 修改逻辑:精细化处理 Crossref 标签 --- + crossref_container_tags = sense_tag.find_all('span', class_=lambda x: x and 'Crossref' in x) + crossref_items_list = [] + for container_tag in crossref_container_tags: + # 查找容器内所有的 crossRef 链接 + crossref_link_tags = container_tag.find_all('a', class_='crossRef') + for link_tag in crossref_link_tags: + crossref_item_dict: Dict[str, Any] = {'sense_id': sense_id} + + # 1. 尝试从 link_tag 前面的兄弟节点 (通常是 REFLEX) 获取描述性文本 + # text_parts = [] + # # 遍历 link_tag 之前的直接兄弟节点 + # prev_sibling = link_tag.previous_sibling + # while prev_sibling and hasattr(prev_sibling, 'name') and prev_sibling.name != 'a': + # # 检查是否是包含文本的标签 (如 REFLEX, neutral span) + # if hasattr(prev_sibling, 'get_text'): + # txt = prev_sibling.get_text(strip=True) + # if txt: + # text_parts.append(txt) + # prev_sibling = prev_sibling.previous_sibling + # # 如果前面没找到描述性文本,则回退到 link_tag 自身的文本 + # if not text_parts: + # link_text = link_tag.get_text(strip=True) + # if link_text: + # text_parts.append(link_text) + # # 组合找到的文本 + # if text_parts: + # crossref_item_dict['text'] = ' '.join(reversed(text_parts)).strip() # 反转是因为我们是向前查找的 + + # 2. 获取 href + href = link_tag.get('href') + if href: + crossref_item_dict['entry_href'] = href + + ref_hwd = link_tag.find('span', class_='REFHWD') + text = ref_hwd.get_text(strip=True) + if text: + crossref_item_dict['text'] = text + + # 检查是否是图片相关的交叉引用 (ldoce-show-image) + if 'ldoce-show-image' in link_tag.get('class', []): + # 提取图片 ID + showid = link_tag.get('showid', '') + if showid: + crossref_item_dict['show_id'] = showid + + # --- 修改逻辑:提取完整的 base64 字符串 --- + # 提取 base64 属性值 (可能包含前缀 data:image/...) + full_base64_data = link_tag.get('src', '') + if not full_base64_data: + full_base64_data = link_tag.get('base64', '') + + if full_base64_data and full_base64_data.startswith('data:'): + # --- 新增逻辑:组合 image_filename 并准备图片信息 --- + # 为了文件名更安全,可以对 base64 字符串的一部分进行哈希或截取 + # 这里简化处理,直接用 showid 和 base64 的一部分 (例如前50个字符) 组合 + # 或者使用 base64 字符串的哈希值 + import hashlib + # 使用 base64 字符串的 SHA1 哈希的前16位作为唯一标识符的一部分 + base64_hash = hashlib.sha1(full_base64_data.encode('utf-8')).hexdigest()[:16] + # 组合 file_name + image_filename = f"{showid}_sha1_{base64_hash}" # 推荐使用哈希 + crossref_item_dict['image_filename'] = image_filename + # 可以考虑从 base64 前缀提取 MIME 类型 + mime_type = full_base64_data.split(';')[0].split(':')[1] if ';' in full_base64_data else 'image/jpeg' + + # 准备图片信息字典,供后续存入 dict_media 表 + images_info.append({ + 'sense_id': sense_id, + 'filename': image_filename, + 'src': f"crossref:{showid}", # 可以包含 showid 便于识别 + 'type': 'image_crossref', + 'crossref_showid': showid, + # 存储完整的 base64 数据 + 'crossref_full_base64': full_base64_data, + # 提取图片标题 + 'crossref_title': link_tag.get('title', ''), + 'mime_type': mime_type + }) + else: + crossref_item_dict['image_filename'] = full_base64_data + + # 提取图片标题 (title 属性) + image_title = link_tag.get('title', '') + if image_title: + crossref_item_dict['image_title'] = image_title + + # 提取 LDOCE 版本信息 (从容器 span 标签上获取) + container_classes = container_tag.get('class', []) + version_classes = [cls for cls in container_classes if cls.startswith('LDOCEVERSION_')] + if version_classes: + crossref_item_dict['ldoce_version'] = version_classes[0] + + # 如果提取到了任何信息,则添加到列表 + if crossref_item_dict: + try: + crossref_item = CrossReference(**crossref_item_dict) + crossref_items_list.append(crossref_item) + except Exception as e: + print(f"创建 CrossReference 对象失败: {e}, 数据: {crossref_item_dict}") + + if crossref_items_list: + sense_dict['cross_references'] = crossref_items_list + + # Signpost 和其中文 (SIGNPOST) + signpost_tag = sense_tag.find(class_='SIGNPOST') + if signpost_tag: + # 英文部分是 SIGNPOST 标签本身的内容(不含子标签) + # signpost_en_text = signpost_tag.get_text(strip=True) # 这会包含子标签 cn_txt + # 更精确地获取英文部分 + signpost_parts = [] + for content in signpost_tag.contents: + if isinstance(content, str): + signpost_parts.append(content.strip()) + elif content.name != 'span' or 'cn_txt' not in content.get('class', []): + signpost_parts.append(content.get_text(strip=True)) + sense_dict['signpost_en'] = ' '.join(filter(None, signpost_parts)) + + cn_signpost_tag = signpost_tag.find(class_='cn_txt') + if cn_signpost_tag: + sense_dict['signpost_cn'] = cn_signpost_tag.get_text(strip=True) + + # 定义 (DEF) - 可能有英文和中文 + defs_list = [] + def_tags = sense_tag.find_all(class_='DEF') + i = 0 + while i < len(def_tags): + en_def_tag = def_tags[i] + cn_def_tag = None + # 检查下一个 DEF 是否是中文翻译 + if i + 1 < len(def_tags) and def_tags[i + 1].find(class_='cn_txt'): + cn_def_tag = def_tags[i + 1].find(class_='cn_txt') + i += 2 # 跳过中英文一对 + else: + i += 1 # 只处理英文定义 + + def_en_text = self._extract_text_with_links(en_def_tag) # 处理内部链接 a.defRef + def_cn_text = cn_def_tag.get_text(strip=True) if cn_def_tag else None + + related_in_def_list = [] + for content in en_def_tag.contents: + if hasattr(content, 'name'): + if content.name == 'a' and 'defRef' in content.get('class', []): + # 提取 href 属性中的链接词 + href = content.get('href', '') + # 假设 href 格式为 entry://word 或类似,提取 word 部分 + # 简单处理:去掉前缀,按 '#' 或 '/' 分割取第一部分 + if href: + # 去掉协议部分 + if '://' in href: + word_part = href.split('://', 1)[1] + else: + word_part = href + # 去掉锚点 + word_part = word_part.split('#', 1)[0] + # 去掉查询参数 (如果有的话) + word_part = word_part.split('?', 1)[0] + # 去掉路径中的文件名部分,只保留词 (简单处理) + # 例如 entry://Food, dish-topic food -> Food, dish-topic food + # 例如 entry://red -> red + # 例如 entry://inside#inside__9__a -> inside + related_word = word_part.strip() + if related_word: + related_in_def_list.append(related_word) + + # 过滤掉空定义 + if def_en_text or def_cn_text: + defs_list.append(Definition(en=def_en_text, cn=def_cn_text, related_words=related_in_def_list)) + + if defs_list: + sense_dict['definitions'] = defs_list + + # 例子 (EXAMPLE) + examples_list = [] + example_tags = sense_tag.find_all(class_='EXAMPLE') + for ex_tag in example_tags: + if not isinstance(ex_tag, Tag): + continue + example_dict: Dict[str, Any] = {} + + # 英文例句 (english) + en_span_tag = ex_tag.find(class_='english') + if en_span_tag: + example_dict['en'] = self._extract_text_with_links(en_span_tag) # 处理内部链接 + + # 中文翻译 (cn_txt) + cn_span_tag = ex_tag.find(class_='cn_txt') + if cn_span_tag: + example_dict['cn'] = cn_span_tag.get_text(strip=True) + + # 搭配 (COLLOINEXA) + collocation_tag = ex_tag.find(class_='COLLOINEXA') + if collocation_tag: + # 搭配文本可能需要特殊处理,因为它可能在 en 文本中被高亮 + # 这里简单提取文本 + example_dict['collocation'] = collocation_tag.get_text(strip=True) + + # 例子内链接词 (crossRef in example) + related_in_ex_list = [] + # 查找例子文本内的 defRef 或 crossRef 链接 + if en_span_tag: + ref_tags_in_ex = en_span_tag.find_all('a', class_=['defRef', 'crossRef']) + for ref_tag in ref_tags_in_ex: + ref_text = ref_tag.get_text(strip=True) + if ref_text: + related_in_ex_list.append(ref_text) + if related_in_ex_list: + example_dict['related_words_in_example'] = related_in_ex_list + + # --- 示例音频提取 (关键修改点) --- + # 查找示例音频链接,匹配 href="sound://..." + ex_audio_tag = ex_tag.find('a', class_='speaker exafile', href=lambda x: x and x.startswith('sound://')) + if not ex_audio_tag: + # 更宽松的匹配 class 包含 speaker 和 exafile + ex_audio_tag = ex_tag.find('a', class_=lambda x: x and 'speaker' in x and 'exafile' in x, href=lambda x: x and x.startswith('sound://')) + + if ex_audio_tag: + audio_href = ex_audio_tag.get('href', '') + if audio_href.startswith('sound://'): + example_dict['audio'] = audio_href.replace('sound://', '', 1) + + if example_dict.get('en') or example_dict.get('cn'): # 只添加有内容的例子 + examples_list.append(Example(**example_dict)) + + if examples_list: + sense_dict['examples'] = examples_list + + if sense_dict.get('definitions') or sense_dict.get('examples'): # 只添加有定义或例子的 Sense + senses_list.append(Sense(**sense_dict)) + + if senses_list: + entry['senses'] = senses_list + + word_metadata['dict_list'].append(entry) + + # etym + etym_tag = soup.find('span', class_='etym') + if etym_tag: + etym_map: Dict[str, Any] = {'item': []} + asset_intro = etym_tag.find('span', class_='asset_intro') + if asset_intro: + etym_map['intro'] = asset_intro.get_text(strip=True) + + head_tag = etym_tag.find('span', class_='Head') + if head_tag: + hw_tag = head_tag.find('span', class_='HWD') + if hw_tag: + etym_map['headword'] = hw_tag.get_text(strip=True) + hom_tag = head_tag.find('span', class_='HOMNUM') + if hom_tag: + etym_map['hom_num'] = hom_tag.get_text(strip=True) + + sense_tags = etym_tag.find_all('span', class_='Sense') + for sense_tag in sense_tags: + item: Dict[str, Any] = {} + lang_tag = sense_tag.find('span', class_='LANG') + if lang_tag: + item['language'] = lang_tag.get_text(strip=True).strip() + + origin_tag = sense_tag.find('span', class_='ORIGIN') + if origin_tag: + item['origin'] = origin_tag.get_text(strip=True).strip() + + etym_map['item'].append(EtymologyItem(**item)) + + word_metadata['etymology'] = Etymology(**etym_map) + + # --- 7. 创建 WordMetaData 对象 --- + if word_metadata: + try: + metadata = WordMetaData(**word_metadata) + return metadata, images_info # images_info 在此方法中未填充 + except Exception as e: + print(f"WordMetaData 验证失败,原始数据: {json.dumps(word_metadata, ensure_ascii=False, indent=2)}") + print(f"验证错误: {e}") + # 可以选择返回 None 或者不验证的 dict + return None, images_info + else: + return None, images_info + + except Exception as e: + print(f"解析 HTML 时出错: {e}") + import traceback + traceback.print_exc() # 打印详细错误信息 + return None, images_info + + + def _extract_text_with_links(self, tag: Tag) -> str: + """提取标签文本,保留内部链接词的文本,但不保留 HTML 结构。 + 例如: 'a hard round fruit' -> 'a hard round fruit' + """ + if not tag: + return "" + parts = [] + for content in tag.contents: + if isinstance(content, str): + parts.append(content.strip()) + elif hasattr(content, 'name') and content.name == 'a' and 'defRef' in content.get('class', []): + # 提取链接词的文本 + parts.append(content.get_text(strip=True)) + elif hasattr(content, 'name'): # 其他标签,递归提取文本 + parts.append(self._extract_text_with_links(content)) + # 忽略其他非标签、非文本内容 + return ' '.join(filter(None, parts)) # 过滤空字符串并用空格连接 + + def save_entry_images(self, entry_id: int, word: str, images_info: List[Dict]) -> None: + """ + 保存词条的图片信息到 dict_media 表 + """ + from psycopg2.extras import Json + import hashlib + + cursor = self.conn.cursor() + + try: + for img_info in images_info: + # 检查是否存在 crossref_full_base64 并尝试解码 + image_data = None + if 'crossref_full_base64' in img_info: + try: + # Base64 字符串可能包含前缀 (如 data:image/jpeg;base64,...) + b64_string = img_info['crossref_full_base64'] + if b64_string.startswith('data:'): + # 分割并获取实际的 base64 数据部分 + header, b64_data = b64_string.split(',', 1) + else: + # 如果没有前缀,整个字符串就是 base64 数据 + b64_data = b64_string + + # 解码 Base64 字符串为二进制数据 + image_data = base64.b64decode(b64_data) + # print(f"成功解码 crossref 图片: {img_info.get('filename', 'unknown')}") + except Exception as e: + print( + f"解码 crossref_full_base64 数据失败 (文件名: {img_info.get('filename', 'unknown')}): {e}") + # 如果解码失败,可以选择跳过这个图片或记录错误 + # continue # 跳过当前图片 + # 或者保留 image_data 为 None,后续逻辑会处理 + + # 如果上面解码成功,使用解码后的 image_data;否则检查是否已有 'image_data' (来自 extract_images_from_definition) + if image_data is None and 'image_data' in img_info: + image_data = img_info['image_data'] + + filename = img_info['filename'] + src = img_info['src'] + file_type = img_info['type'] + details = { + 'sense_id': img_info.get('sense_id'), + 'src': src, + 'word': word, + 'mime_type': img_info.get('mime_type'), + 'show_id': img_info.get('crossref_showid'), + 'crossref_title': img_info.get('crossref_title'), + } + # 移除 details 中的 None 值 (可选,保持数据整洁) + details = {k: v for k, v in details.items() if v is not None} + + # 检查是否已存在相同的图片记录 + cursor.execute(''' + SELECT id + FROM dict_media + WHERE file_name = %s + AND dict_id = %s + ''', (filename, entry_id)) + + if cursor.fetchone() is None: + # 处理图片数据 + if image_data: + # 有实际的图片二进制数据(base64 解码后的数据) + file_hash = hashlib.sha256(image_data).hexdigest() + + cursor.execute(''' + INSERT INTO dict_media (dict_id, file_name, file_type, file_data, file_hash, details) + VALUES (%s, %s, %s, %s, %s, %s) + ''', (entry_id, filename, file_type, psycopg2.Binary(image_data), file_hash, Json(details))) + else: + # 没有实际图片数据,可能是文件路径引用 + file_hash = hashlib.sha256(src.encode()).hexdigest() + + cursor.execute(''' + INSERT INTO dict_media (dict_id, file_name, file_type, file_data, file_hash, details) + VALUES (%s, %s, %s, %s, %s) + ''', (entry_id, filename, file_type, src, file_hash, Json(details))) + + except Exception as e: + print(f"保存词条 '{word}' 的图片信息时出错: {e}") + + self.conn.commit() + cursor.close() + + def close(self): + """关闭数据库连接""" + if self.conn: + self.conn.close() + + +# 使用示例 +def main(): + # 数据库配置 + db_config = { + 'host': 'localhost', + 'database': 'postgres', + 'user': 'root', + 'password': 'root', + 'port': 5432 + } + + # 文件路径 + mdx_path = './LDOCE5.mdx' + mdd_path = './LDOCE5.mdd' # 可选 + + # 创建解析器实例 + parser = DictionaryParser(db_config) + + try: + # with open('./exported_media/kernel.html', 'r', encoding='utf-8') as file: + # html_str = file.read() + # de,image_info = parser.parse_definition_to_metadata(html_str) + # print(de) + + # 解析词典文件 + parser.parse_mdx_mdd(mdx_path, mdd_path) + + # 可选:导出媒体文件到本地目录 + # parser.export_media_files('./exported_media') + + except Exception as e: + print(f"解析过程中出现错误: {e}") + finally: + parser.close() + + +if __name__ == "__main__": + main() diff --git a/backend/.env.example b/backend/.env.example new file mode 100755 index 0000000..a04fc75 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +# Env: dev、pro +ENVIRONMENT='dev' +# Database +DATABASE_HOST='127.0.0.1' +DATABASE_PORT=3306 +DATABASE_USER='root' +DATABASE_PASSWORD='123456' +# Redis +REDIS_HOST='127.0.0.1' +REDIS_PORT=6379 +REDIS_PASSWORD='' +REDIS_DATABASE=0 +# Token +TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100755 index 0000000..6f7b18c --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100755 index 0000000..cb69830 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,100 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(hour).2d-%%(minute).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. Valid values are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # default: use os.pathsep + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql+asyncpg://root:root@127.0.0.1:5432/db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100755 index 0000000..b18ae93 --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100755 index 0000000..6298540 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# ruff: noqa: E402 +import asyncio +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, MetaData +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import AsyncEngine + +from alembic import context + +sys.path.append('../') + +from backend.core import path_conf + +if not os.path.exists(path_conf.ALEMBIC_VERSION_DIR): + os.makedirs(path_conf.ALEMBIC_VERSION_DIR) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# https://alembic.sqlalchemy.org/en/latest/autogenerate.html#autogenerating-multiple-metadata-collections +from backend.app.admin.model import MappedBase + +target_metadata = [ + MappedBase.metadata, +] + +# other values from the config, defined by the needs of env.py, +from backend.database.db import SQLALCHEMY_DATABASE_URL + +config.set_main_option('sqlalchemy.url', SQLALCHEMY_DATABASE_URL) + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option('sqlalchemy.url') + context.configure( + url=url, + target_metadata=target_metadata, # type: ignore + literal_binds=True, + dialect_opts={'paramstyle': 'named'}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) # type: ignore + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = AsyncEngine( + engine_from_config( + config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool, + future=True, + ) + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/alembic/hooks.py b/backend/alembic/hooks.py new file mode 100755 index 0000000..e69de29 diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100755 index 0000000..c95e0ea --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100755 index 0000000..fbb6a5d --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import os.path + +from backend.core.path_conf import BASE_PATH +from backend.utils.import_parse import get_model_objects + + +def get_app_models() -> list[type]: + """获取 app 所有模型类""" + app_path = os.path.join(BASE_PATH, 'app') + list_dirs = os.listdir(app_path) + + apps = [] + + for d in list_dirs: + if os.path.isdir(os.path.join(app_path, d)) and d != '__pycache__': + apps.append(d) + + objs = [] + + for app in apps: + module_path = f'backend.app.{app}.model' + obj = get_model_objects(module_path) + if obj: + objs.extend(obj) + + return objs + + +# import all app models for auto create db tables +for cls in get_app_models(): + class_name = cls.__name__ + if class_name not in globals(): + globals()[class_name] = cls diff --git a/backend/app/admin/__init__.py b/backend/app/admin/__init__.py new file mode 100755 index 0000000..68bef65 --- /dev/null +++ b/backend/app/admin/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/backend/app/admin/api/__init__.py b/backend/app/admin/api/__init__.py new file mode 100755 index 0000000..6f7b18c --- /dev/null +++ b/backend/app/admin/api/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/app/admin/api/router.py b/backend/app/admin/api/router.py new file mode 100755 index 0000000..577ec7b --- /dev/null +++ b/backend/app/admin/api/router.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import APIRouter + +from backend.app.admin.api.v1.wx import router as wx_router +from backend.app.admin.api.v1.wxpay_callback import router as wx_pay_router +from backend.app.admin.api.v1.account import router as account_router +from backend.app.admin.api.v1.file import router as file_router +from backend.app.admin.api.v1.dict import router as dict_router +from backend.app.admin.api.v1.audit_log import router as audit_log_router +from backend.app.admin.api.v1.feedback import router as feedback_router +from backend.app.admin.api.v1.coupon import router as coupon_router +from backend.app.admin.api.v1.notification import router as notification_router +from backend.core.conf import settings + +v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH) + +v1.include_router(account_router, prefix='/account', tags=['账户服务']) +v1.include_router(wx_pay_router, prefix="/wxpay", tags=["WeChat Pay Callback"]) +v1.include_router(wx_router, prefix='/wx', tags=['微信服务']) +v1.include_router(file_router, prefix='/file', tags=['文件服务']) +v1.include_router(dict_router, prefix='/dict', tags=['字典服务']) +v1.include_router(audit_log_router, prefix='/audit', tags=['审计日志服务']) +v1.include_router(feedback_router, prefix='/feedback', tags=['反馈服务']) +v1.include_router(coupon_router, prefix='/coupon', tags=['兑换券服务']) +v1.include_router(notification_router, prefix='/notification', tags=['消息通知服务']) \ No newline at end of file diff --git a/backend/app/admin/api/v1/__init__.py b/backend/app/admin/api/v1/__init__.py new file mode 100755 index 0000000..6f7b18c --- /dev/null +++ b/backend/app/admin/api/v1/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/app/admin/api/v1/account.py b/backend/app/admin/api/v1/account.py new file mode 100755 index 0000000..b84939f --- /dev/null +++ b/backend/app/admin/api/v1/account.py @@ -0,0 +1,54 @@ +# routers/account.py + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from backend.app.admin.schema.usage import PurchaseRequest, SubscriptionRequest +from backend.app.admin.service.ad_share_service import AdShareService +from backend.app.admin.service.refund_service import RefundService +from backend.app.admin.service.subscription_service import SubscriptionService +from backend.app.admin.service.usage_service import UsageService +from backend.common.exception import errors +from backend.common.response.response_schema import response_base +from backend.common.security.jwt import DependsJwtAuth + +router = APIRouter() + +@router.post("/purchase", dependencies=[DependsJwtAuth]) +async def purchase_times_api( + purchase_request: PurchaseRequest, + request: Request +): + await UsageService.purchase_times(request.user.id, purchase_request) + return response_base.success(data={"msg": "充值成功"}) + +@router.post("/subscribe", dependencies=[DependsJwtAuth]) +async def subscribe_api( + sub_request: SubscriptionRequest, + request: Request +): + await SubscriptionService.subscribe(request.user.id, sub_request.plan) + return response_base.success(data={"msg": "订阅成功"}) + +@router.post("/ad", dependencies=[DependsJwtAuth]) +async def ad_grant_api(request: Request): + await AdShareService.grant_times_by_ad(request.user.id) + return response_base.success(data={"msg": "已通过广告获得次数"}) + +@router.post("/share", dependencies=[DependsJwtAuth]) +async def share_grant_api(request: Request): + await AdShareService.grant_times_by_share(request.user.id) + return response_base.success(data={"msg": "已通过分享获得次数"}) + +@router.post("/refund/{order_id}", dependencies=[DependsJwtAuth]) +async def apply_refund( + order_id: int, + request: Request, + reason: str = "用户申请退款" +): + """ + 申请退款 + """ + try: + result = await RefundService.process_refund(request.user.id, order_id, reason) + return response_base.success(data={"msg": "退款申请已提交", "data": result}) + except Exception as e: + raise errors.RequestError(msg=str(e)) \ No newline at end of file diff --git a/backend/app/admin/api/v1/audit_log.py b/backend/app/admin/api/v1/audit_log.py new file mode 100755 index 0000000..415f231 --- /dev/null +++ b/backend/app/admin/api/v1/audit_log.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import APIRouter, Depends, Request +from fastapi_pagination import Page +from fastapi_pagination.ext.sqlalchemy import paginate + +from backend.app.admin.service.audit_log_service import audit_log_service +from backend.app.admin.schema.audit_log import AuditLogHistorySchema, AuditLogStatisticsSchema, DailySummaryPageSchema +from backend.app.admin.tasks import wx_user_index_history +from backend.common.response.response_schema import response_base, ResponseSchemaModel +from backend.common.security.jwt import DependsJwtAuth + +router = APIRouter() + + +@router.get("/history", summary="获取用户识别历史记录", dependencies=[DependsJwtAuth]) +async def get_user_recognition_history( + request:Request, + page: int = 1, + size: int = 20 +): + """ + 通过用户ID查询历史记录 + """ + history_items, total = await audit_log_service.get_user_recognition_history( + user_id=request.user.id, + page=page, + size=size + ) + + # await wx_user_index_history() + + # 创建分页结果 + result = { + "items": [item.model_dump() for item in history_items], + "total": total, + "page": page, + "size": size + } + + return response_base.success(data=result) + + +@router.get("/statistics", summary="获取用户识别统计信息", dependencies=[DependsJwtAuth]) +async def get_user_recognition_statistics(request:Request): + """ + 统计用户 recognition 类型的使用记录 + 返回历史总量和当天总量 + """ + total_count, today_count, image_count = await audit_log_service.get_user_recognition_statistics(user_id=request.user.id) + + result = AuditLogStatisticsSchema( + total_count=total_count, + today_count=today_count, + image_count=image_count + ) + + return response_base.success(data=result) + + +@router.get("/summary", summary="获取用户每日识别汇总记录", dependencies=[DependsJwtAuth]) +async def get_user_daily_summary( + request: Request, + page: int = 1, + size: int = 20 +): + """ + 通过用户ID查询每日识别汇总记录,按创建时间降序排列 + """ + summary_items, total = await audit_log_service.get_user_daily_summaries( + user_id=request.user.id, + page=page, + size=size + ) + + # await wx_user_index_history() + + # 创建分页结果 + result = DailySummaryPageSchema( + items=summary_items, + total=total, + page=page, + size=size + ) + + return response_base.success(data=result) + + +@router.get("/today_summary", summary="获取用户今日识别汇总记录", dependencies=[DependsJwtAuth]) +async def get_user_today_summary( + request: Request, + page: int = 1, + size: int = 20 +): + """ + 通过用户ID查询每日识别汇总记录,按创建时间降序排列 + """ + summary_items, total = await audit_log_service.get_user_today_summaries( + user_id=request.user.id, + page=page, + size=size + ) + + # await wx_user_index_history() + + # 创建分页结果 + result = DailySummaryPageSchema( + items=summary_items, + total=total, + page=page, + size=size + ) + + return response_base.success(data=result) \ No newline at end of file diff --git a/backend/app/admin/api/v1/auth/__init__.py b/backend/app/admin/api/v1/auth/__init__.py new file mode 100755 index 0000000..60b1926 --- /dev/null +++ b/backend/app/admin/api/v1/auth/__init__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import APIRouter + +from backend.app.admin.api.v1.auth.auth import router as auth_router +from backend.app.admin.api.v1.auth.captcha import router as captcha_router + +router = APIRouter(prefix='/auth') + +router.include_router(auth_router, tags=['授权']) +router.include_router(captcha_router, prefix='/captcha', tags=['验证码']) diff --git a/backend/app/admin/api/v1/auth/auth.py b/backend/app/admin/api/v1/auth/auth.py new file mode 100755 index 0000000..20dd3bc --- /dev/null +++ b/backend/app/admin/api/v1/auth/auth.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from fastapi import APIRouter, Depends, Request +from fastapi.security import OAuth2PasswordRequestForm + +from backend.app.admin.service.auth_service import auth_service +from backend.common.security.jwt import DependsJwtAuth +from backend.common.response.response_schema import response_base, ResponseModel, ResponseSchemaModel +from backend.app.admin.schema.token import GetSwaggerToken, GetLoginToken +from backend.app.admin.schema.user import AuthLoginParam + +router = APIRouter() + + +@router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷进行 swagger 认证') +async def swagger_login(form_data: OAuth2PasswordRequestForm = Depends()) -> GetSwaggerToken: + token, user = await auth_service.swagger_login(form_data=form_data) + return GetSwaggerToken(access_token=token, user=user) # type: ignore + + +@router.post('/login', summary='验证码登录') +async def user_login(request: Request, obj: AuthLoginParam) -> ResponseSchemaModel[GetLoginToken]: + data = await auth_service.login(request=request, obj=obj) + return response_base.success(data=data) + + +@router.post('/logout', summary='用户登出', dependencies=[DependsJwtAuth]) +async def user_logout() -> ResponseModel: + return response_base.success() diff --git a/backend/app/admin/api/v1/auth/captcha.py b/backend/app/admin/api/v1/auth/captcha.py new file mode 100755 index 0000000..a953eb6 --- /dev/null +++ b/backend/app/admin/api/v1/auth/captcha.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fast_captcha import img_captcha +from fastapi import APIRouter, Depends, Request +from fastapi_limiter.depends import RateLimiter +from starlette.concurrency import run_in_threadpool + +from backend.app.admin.schema.captcha import GetCaptchaDetail +from backend.common.response.response_schema import ResponseSchemaModel, response_base +from backend.core.conf import settings +from backend.database.db import uuid4_str +from backend.database.redis import redis_client + +router = APIRouter() + + +@router.get( + '', + summary='获取登录验证码', + dependencies=[Depends(RateLimiter(times=5, seconds=10))], +) +async def get_captcha(request: Request) -> ResponseSchemaModel[GetCaptchaDetail]: + """ + 此接口可能存在性能损耗,尽管是异步接口,但是验证码生成是IO密集型任务,使用线程池尽量减少性能损耗 + """ + img_type: str = 'base64' + img, code = await run_in_threadpool(img_captcha, img_byte=img_type) + uuid = uuid4_str() + request.app.state.captcha_uuid = uuid + await redis_client.set( + f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}', + code, + ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS, + ) + data = GetCaptchaDetail(image_type=img_type, image=img) + return response_base.success(data=data) diff --git a/backend/app/admin/api/v1/coupon.py b/backend/app/admin/api/v1/coupon.py new file mode 100755 index 0000000..afe8faa --- /dev/null +++ b/backend/app/admin/api/v1/coupon.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field +from typing import List, Optional + +from backend.app.admin.service.coupon_service import CouponService +from backend.common.response.response_schema import response_base +from backend.common.security.jwt import DependsJwtAuth + +router = APIRouter() + +class RedeemCouponRequest(BaseModel): + code: str = Field(..., min_length=1, max_length=32, description="兑换码") + +class CreateCouponRequest(BaseModel): + duration: int = Field(..., gt=0, description="兑换时长(分钟)") + count: int = Field(1, ge=1, le=1000, description="生成数量") + expires_days: Optional[int] = Field(None, ge=1, description="过期天数") + +class CouponHistoryResponse(BaseModel): + code: str + duration: int + used_at: str + +@router.post("/redeem", dependencies=[DependsJwtAuth]) +async def redeem_coupon_api( + request: Request, + redeem_request: RedeemCouponRequest +): + """ + 兑换兑换券 + """ + result = await CouponService.redeem_coupon(redeem_request.code, request.user.id) + return response_base.success(data=result) + +@router.get("/history", dependencies=[DependsJwtAuth]) +async def get_coupon_history_api( + request: Request, + limit: int = 100 +): + """ + 获取用户兑换历史 + """ + history = await CouponService.get_user_coupon_history(request.user.id, limit) + return response_base.success(data=history) + +# 管理员接口,用于批量生成兑换券 +@router.post("/generate", dependencies=[DependsJwtAuth]) +async def generate_coupons_api( + request: Request, + create_request: CreateCouponRequest +): + """ + 批量生成兑换券(管理员接口) + """ + # 这里应该添加管理员权限验证 + # 为简化示例,暂时省略权限验证 + + coupons = await CouponService.batch_create_coupons( + create_request.count, + create_request.duration, + create_request.expires_days + ) + + return response_base.success(data={ + "count": len(coupons), + "message": f"成功生成{len(coupons)}个兑换券" + }) \ No newline at end of file diff --git a/backend/app/admin/api/v1/dict.py b/backend/app/admin/api/v1/dict.py new file mode 100755 index 0000000..ecf92f0 --- /dev/null +++ b/backend/app/admin/api/v1/dict.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter, Query, Path, Response +from fastapi.responses import StreamingResponse +import io + +from backend.app.admin.schema.dict import DictWordResponse +from backend.app.admin.service.dict_service import dict_service +from backend.common.response.response_schema import response_base, ResponseSchemaModel +from backend.common.security.jwt import DependsJwtAuth +from backend.middleware import tencent_cloud + +router = APIRouter() + + +@router.get("/word/{word}", summary="根据单词查询字典信息", dependencies=[DependsJwtAuth]) +async def get_word_info( + word: str = Path(..., description="要查询的单词", min_length=1, max_length=100) +) -> ResponseSchemaModel[DictWordResponse]: + """ + 根据单词查询字典信息 + + Args: + word: 要查询的单词 + + Returns: + 包含单词详细信息的响应,包括: + - senses: 义项列表 + - frequency: 频率信息 + - pronunciations: 发音信息 + """ + # from backend.middleware.tencent_cloud import TencentCloud + # data = await TencentCloud().text_to_speak(image_id=2088374826979950592,content='green vegetable cut into small pieces and added to food',image_text_id=2088375029640331267,user_id=2083326996703739904) + result = await dict_service.get_word_info(word) + return response_base.success(data=result) + + +@router.get("/check/{word}", summary="检查单词是否存在", dependencies=[DependsJwtAuth]) +async def check_word_exists( + word: str = Path(..., description="要检查的单词", min_length=1, max_length=100) +) -> ResponseSchemaModel[bool]: + """ + 检查单词是否在字典中存在 + + Args: + word: 要检查的单词 + + Returns: + 布尔值,表示单词是否存在 + """ + result = await dict_service.check_word_exists(word) + return response_base.success(data=result) + + +@router.get("/audio/{file_name:path}", summary="播放音频文件", dependencies=[DependsJwtAuth]) +async def play_audio( + file_name: str = Path(..., description="音频文件名", min_length=1, max_length=255) +) -> StreamingResponse: + """ + 根据文件名播放音频文件 + + Args: + file_name: 音频文件名 + + Returns: + 音频文件流 + """ + audio_data = await dict_service.get_audio_data(file_name) + if not audio_data: + # 返回空的响应或默认音频 + return StreamingResponse(io.BytesIO(b""), media_type="audio/mpeg") + + return StreamingResponse(io.BytesIO(audio_data), media_type="audio/mpeg") \ No newline at end of file diff --git a/backend/app/admin/api/v1/feedback.py b/backend/app/admin/api/v1/feedback.py new file mode 100755 index 0000000..374c859 --- /dev/null +++ b/backend/app/admin/api/v1/feedback.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, Request + +from backend.app.admin.schema.feedback import CreateFeedbackParam, UpdateFeedbackParam, FeedbackInfoSchema +from backend.app.admin.service.feedback_service import feedback_service +from backend.common.response.response_schema import response_base, ResponseModel, ResponseSchemaModel +from backend.common.security.jwt import DependsJwtAuth + +router = APIRouter() + + +@router.post('', summary='创建用户反馈', dependencies=[DependsJwtAuth]) +async def create_feedback(request: Request, obj: CreateFeedbackParam) -> ResponseSchemaModel[FeedbackInfoSchema]: + """创建用户反馈""" + # 从JWT中获取用户ID + user_id = request.user.id + feedback = await feedback_service.create_feedback(user_id, obj) + return response_base.success(data=feedback) + + +@router.get('/{feedback_id}', summary='获取反馈详情', dependencies=[DependsJwtAuth]) +async def get_feedback(feedback_id: int) -> ResponseSchemaModel[FeedbackInfoSchema]: + """获取反馈详情""" + feedback = await feedback_service.get_feedback(feedback_id) + if not feedback: + return response_base.fail(msg='反馈不存在') + return response_base.success(data=feedback) + + +@router.get('', summary='获取反馈列表', dependencies=[DependsJwtAuth]) +async def get_feedback_list( + user_id: int = None, + status: str = None, + category: str = None, + limit: int = 10, + offset: int = 0 +) -> ResponseSchemaModel[list[FeedbackInfoSchema]]: + """获取反馈列表""" + feedbacks = await feedback_service.get_feedback_list(user_id, status, category, limit, offset) + return response_base.success(data=feedbacks) + + +@router.put('/{feedback_id}', summary='更新反馈状态', dependencies=[DependsJwtAuth]) +async def update_feedback(feedback_id: int, obj: UpdateFeedbackParam) -> ResponseModel: + """更新反馈状态""" + success = await feedback_service.update_feedback(feedback_id, obj) + if not success: + return response_base.fail(msg='反馈不存在或更新失败') + return response_base.success() + + +@router.delete('/{feedback_id}', summary='删除反馈', dependencies=[DependsJwtAuth]) +async def delete_feedback(feedback_id: int) -> ResponseModel: + """删除反馈""" + success = await feedback_service.delete_feedback(feedback_id) + if not success: + return response_base.fail(msg='反馈不存在或删除失败') + return response_base.success() \ No newline at end of file diff --git a/backend/app/admin/api/v1/file.py b/backend/app/admin/api/v1/file.py new file mode 100755 index 0000000..383125e --- /dev/null +++ b/backend/app/admin/api/v1/file.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, UploadFile, File, Query, Depends, Response + +from backend.app.admin.schema.file import FileUploadResponse +from backend.app.admin.service.file_service import file_service +from backend.common.response.response_schema import response_base, ResponseSchemaModel +from backend.common.security.jwt import DependsJwtAuth + +router = APIRouter() + + +@router.post("/upload", summary="上传文件", dependencies=[DependsJwtAuth]) +async def upload_file( + file: UploadFile = File(...), +) -> ResponseSchemaModel[FileUploadResponse]: + """上传文件""" + result = await file_service.upload_file(file) + return response_base.success(data=result) + + +@router.get("/{file_id}", summary="下载文件", dependencies=[DependsJwtAuth]) +# @router.get("/{file_id}", summary="下载文件") +async def download_file(file_id: int) -> Response: + """下载文件""" + try: + content, filename, content_type = await file_service.download_file(file_id) + headers = { + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Type": content_type + } + return Response(content=content, headers=headers) + except Exception as e: + return Response(content=str(e), status_code=404) + + +@router.delete("/{file_id}", summary="删除文件", dependencies=[DependsJwtAuth]) +async def delete_file(file_id: int) -> ResponseSchemaModel[bool]: + """删除文件""" + result = await file_service.delete_file(file_id) + if not result: + return await response_base.fail(message="文件不存在或删除失败") + return await response_base.success(data=result) \ No newline at end of file diff --git a/backend/app/admin/api/v1/notification.py b/backend/app/admin/api/v1/notification.py new file mode 100755 index 0000000..653ea80 --- /dev/null +++ b/backend/app/admin/api/v1/notification.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field +from typing import List, Optional + +from backend.app.admin.service.notification_service import NotificationService +from backend.common.response.response_schema import response_base +from backend.common.security.jwt import DependsJwtAuth + +router = APIRouter() + +class CreateNotificationRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=255, description="通知标题") + content: str = Field(..., min_length=1, description="通知内容") + image_url: Optional[str] = Field(None, max_length=512, description="图片URL(可选)") + +class MarkAsReadRequest(BaseModel): + notification_ids: List[int] = Field(..., description="要标记为已读的通知ID列表") + +class NotificationResponse(BaseModel): + id: int + notification_id: int + title: str + content: str + image_url: Optional[str] + is_read: Optional[bool] + received_at: str + read_at: Optional[str] + +@router.get("/list", dependencies=[DependsJwtAuth]) +async def get_notifications_api( + request: Request, + limit: int = 100 +): + """ + 获取用户消息通知列表 + """ + notifications = await NotificationService.get_user_notifications(request.user.id, limit) + return response_base.success(data=notifications) + +@router.get("/unread", dependencies=[DependsJwtAuth]) +async def get_unread_notifications_api( + request: Request, + limit: int = 100 +): + """ + 获取用户未读消息通知列表 + """ + notifications = await NotificationService.get_unread_notifications(request.user.id, limit) + return response_base.success(data=notifications) + +@router.get("/unread/count", dependencies=[DependsJwtAuth]) +async def get_unread_count_api( + request: Request +): + """ + 获取用户未读消息通知数量 + """ + count = await NotificationService.get_unread_count(request.user.id) + return response_base.success(data={"count": count}) + +@router.post("/{notification_id}/read", dependencies=[DependsJwtAuth]) +async def mark_as_read_api( + request: Request, + notification_id: int +): + """ + 标记指定通知为已读 + """ + success = await NotificationService.mark_notification_as_read(notification_id, request.user.id) + if success: + return response_base.success(data={"msg": "标记成功"}) + else: + return response_base.fail(data={"msg": "标记失败"}) + +# 管理员接口,用于创建通知 +@router.post("/create", dependencies=[DependsJwtAuth]) +async def create_notification_api( + request: Request, + create_request: CreateNotificationRequest +): + """ + 创建消息通知(管理员接口) + """ + # 这里应该添加管理员权限验证 + # 为简化示例,暂时省略权限验证 + + notification = await NotificationService.create_notification( + create_request.title, + create_request.content, + create_request.image_url, + request.user.id + ) + + return response_base.success(data={ + "id": notification.id, + "msg": "通知创建成功" + }) \ No newline at end of file diff --git a/backend/app/admin/api/v1/user.py b/backend/app/admin/api/v1/user.py new file mode 100755 index 0000000..ddd6306 --- /dev/null +++ b/backend/app/admin/api/v1/user.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Annotated + +from fastapi import APIRouter, Query + +from backend.common.security.jwt import DependsJwtAuth +from backend.common.pagination import paging_data, DependsPagination, PageData +from backend.common.response.response_schema import response_base, ResponseModel, ResponseSchemaModel +from backend.database.db import CurrentSession +from backend.app.admin.schema.user import ( + RegisterUserParam, + GetUserInfoDetail, + ResetPassword, + UpdateUserParam, + AvatarParam, +) +from backend.app.admin.service.wx_user_service import WxUserService + +router = APIRouter() + + +@router.post('/register', summary='用户注册') +async def user_register(obj: RegisterUserParam) -> ResponseModel: + await WxUserService.register(obj=obj) + return response_base.success() + + +@router.post('/password/reset', summary='密码重置', dependencies=[DependsJwtAuth]) +async def password_reset(obj: ResetPassword) -> ResponseModel: + count = await WxUserService.pwd_reset(obj=obj) + if count > 0: + return response_base.success() + return response_base.fail() + + +@router.get('/{username}', summary='查看用户信息', dependencies=[DependsJwtAuth]) +async def get_user(username: str) -> ResponseSchemaModel[GetUserInfoDetail]: + data = await WxUserService.get_userinfo(username=username) + return response_base.success(data=data) + + +@router.put('/{username}', summary='更新用户信息', dependencies=[DependsJwtAuth]) +async def update_userinfo(username: str, obj: UpdateUserParam) -> ResponseModel: + count = await WxUserService.update(username=username, obj=obj) + if count > 0: + return response_base.success() + return response_base.fail() + + +@router.put('/{username}/avatar', summary='更新头像', dependencies=[DependsJwtAuth]) +async def update_avatar(username: str, avatar: AvatarParam) -> ResponseModel: + count = await WxUserService.update_avatar(username=username, avatar=avatar) + if count > 0: + return response_base.success() + return response_base.fail() + + +@router.get( + '', + summary='(模糊条件)分页获取所有用户', + dependencies=[ + DependsJwtAuth, + DependsPagination, + ], +) +async def get_all_users( + db: CurrentSession, + username: Annotated[str | None, Query()] = None, + phone: Annotated[str | None, Query()] = None, + status: Annotated[int | None, Query()] = None, +) -> ResponseSchemaModel[PageData[GetUserInfoDetail]]: + user_select = await WxUserService.get_select(username=username, phone=phone, status=status) + page_data = await paging_data(db, user_select) + return response_base.success(data=page_data) + + +# @router.delete( +# path='/{username}', +# summary='用户注销', +# description='用户注销 != 用户登出,注销之后用户将从数据库删除', +# dependencies=[DependsJwtAuth], +# ) +# async def delete_user(current_user: CurrentUser, username: str) -> ResponseModel: +# count = await WxUserService.delete(current_user=current_user, username=username) +# if count > 0: +# return response_base.success() +# return response_base.fail() diff --git a/backend/app/admin/api/v1/wx.py b/backend/app/admin/api/v1/wx.py new file mode 100755 index 0000000..2837dfe --- /dev/null +++ b/backend/app/admin/api/v1/wx.py @@ -0,0 +1,95 @@ +# routers/wx.py +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi_limiter.depends import RateLimiter + +from backend.app.admin.schema.token import GetWxLoginToken +from backend.app.admin.schema.wx import WxLoginRequest, TokenResponse, UserInfo, UpdateUserSettingsRequest, GetUserSettingsResponse, DictLevel + +from backend.app.admin.service.wx_service import wx_service +from backend.common.response.response_schema import response_base, ResponseSchemaModel +from backend.core.wx_integration import verify_wx_code + +router = APIRouter() + + +@router.post("/login", + summary="微信登录", + dependencies=[Depends(RateLimiter(times=5, minutes=1))]) +async def wechat_login( + request: Request, response: Response, + wx_request: WxLoginRequest +) -> ResponseSchemaModel[GetWxLoginToken]: + """ + 微信小程序登录接口 + - **code**: 微信小程序前端获取的临时code + - **encrypted_data** (可选): 加密的用户信息 + - **iv** (可选): 加密算法的初始向量 + """ + # 验证微信code并获取用户信息 + wx_result = await verify_wx_code(wx_request.code) + if not wx_result: + raise HTTPException(status_code=401, detail="微信认证失败") + + # 处理用户登录逻辑 + result = await wx_service.login( + request=request, response=response, + openid=wx_result.get("openid"), + session_key=wx_result.get("session_key"), + encrypted_data=wx_request.encrypted_data, + iv=wx_request.iv + ) + return response_base.success(data=result) + + +# @router.put("/settings", summary="更新用户设置", dependencies=[DependsJwtAuth]) +# async def update_user_settings( +# request: Request, +# settings: UpdateUserSettingsRequest +# ) -> ResponseSchemaModel[None]: +# """ +# 更新用户设置 +# """ +# +# # 更新用户设置 +# await wx_service.update_user_settings( +# user_id=request.user.id, +# dict_level=settings.dict_level +# ) +# +# return response_base.success() +# +# +# @router.get("/settings", summary="获取用户设置", dependencies=[DependsJwtAuth]) +# async def get_user_settings( +# request: Request +# ) -> ResponseSchemaModel[GetUserSettingsResponse]: +# """ +# 获取用户设置 +# """ +# # 从请求中获取用户ID(实际项目中应该从JWT token中获取) +# user_id = getattr(request.state, 'user_id', None) +# if not user_id: +# raise HTTPException(status_code=401, detail="未授权访问") +# +# # 获取用户信息 +# async with async_db_session() as db: +# user = await wx_user_dao.get(db, user_id) +# if not user: +# raise HTTPException(status_code=404, detail="用户不存在") +# +# # 提取设置信息 +# dict_level = None +# if user.profile and isinstance(user.profile, dict): +# dict_level_value = user.profile.get("dict_level") +# if dict_level_value: +# # 将字符串值转换为枚举值 +# try: +# dict_level = DictLevel(dict_level_value) +# except ValueError: +# pass # 无效值,保持为None +# +# response_data = GetUserSettingsResponse( +# dict_level=dict_level +# ) +# +# return response_base.success(data=response_data) \ No newline at end of file diff --git a/backend/app/admin/api/v1/wxpay_callback.py b/backend/app/admin/api/v1/wxpay_callback.py new file mode 100755 index 0000000..4659c56 --- /dev/null +++ b/backend/app/admin/api/v1/wxpay_callback.py @@ -0,0 +1,150 @@ +from fastapi import APIRouter, Request, BackgroundTasks +from wechatpy.exceptions import InvalidSignatureException +from backend.app.admin.crud.order_crud import order_dao +from backend.app.admin.crud.user_account_crud import user_account_dao +from backend.app.admin.model import Order, UserAccount +from backend.app.admin.service.refund_service import RefundService +from backend.app.admin.service.subscription_service import SUBSCRIPTION_PLANS +from backend.database.db import async_db_session +from backend.common.log import log as logger +from backend.app.admin.crud.usage_log_crud import usage_log_dao +from sqlalchemy import select, update +from datetime import datetime +import hashlib + +from backend.utils.wx_pay import wx_pay_utils + +router = APIRouter() + +def verify_wxpay_signature(data: dict, api_key: str) -> bool: + """ + 验证微信支付回调签名 + """ + try: + # 获取签名 + sign = data.pop('sign', None) + if not sign: + return False + + # 重新计算签名 + sorted_keys = sorted(data.keys()) + stringA = '&'.join(f"{key}={data[key]}" for key in sorted_keys if data[key]) + stringSignTemp = f"{stringA}&key={api_key}" + calculated_sign = hashlib.md5(stringSignTemp.encode('utf-8')).hexdigest().upper() + + return sign == calculated_sign + except Exception as e: + logger.error(f"签名验证异常: {str(e)}") + return False + + +@router.post("/notify") +async def wxpay_notify(request: Request): + """ + 微信支付异步通知处理(增强安全性和幂等性) + """ + try: + # 读取原始数据 + body = await request.body() + body_str = body.decode('utf-8') + + # 解析微信回调数据 + result = wx_pay_utils.parse_payment_result(body) + + # 验证签名(增强安全性) + from backend.core.conf import settings + if not verify_wxpay_signature(result.copy(), settings.WECHAT_PAY_API_KEY): + logger.warning("微信支付回调签名验证失败") + return {"return_code": "FAIL", "return_msg": "签名验证失败"} + + if result['return_code'] == 'SUCCESS' and result['result_code'] == 'SUCCESS': + out_trade_no = result['out_trade_no'] + transaction_id = result['transaction_id'] + + async with async_db_session.begin() as db: + # 使用SELECT FOR UPDATE确保并发安全 + stmt = select(Order).where(Order.id == int(out_trade_no)).with_for_update() + order_result = await db.execute(stmt) + order = order_result.scalar_one_or_none() + + if not order: + logger.warning(f"订单不存在: {out_trade_no}") + return {"return_code": "SUCCESS"} + + # 幂等性检查 + if order.processed_at is not None: + logger.info(f"订单已处理过: {out_trade_no}") + return {"return_code": "SUCCESS"} + + if order.status != 'pending': + logger.warning(f"订单状态异常: {out_trade_no}, status: {order.status}") + return {"return_code": "SUCCESS"} + + # 更新订单状态 + order.status = 'completed' + order.transaction_id = transaction_id + order.processed_at = datetime.now() + await order_dao.update(db, order.id, order) + + # 如果是订阅订单,更新用户订阅信息 + if order.order_type == 'subscription': + # 使用SELECT FOR UPDATE锁定用户账户 + account_stmt = select(UserAccount).where(UserAccount.user_id == order.user_id).with_for_update() + account_result = await db.execute(account_stmt) + user_account = account_result.scalar_one_or_none() + + if user_account: + plan = None + for key, value in SUBSCRIPTION_PLANS.items(): + if value['price'] == order.amount_cents: + plan = key + break + + if plan: + # 处理未用完次数(仅累计一个月) + new_balance = user_account.balance + user_account.carryover_balance + carryover = 0 + + # 如果是续费且当期次数未用完,则累计到下一期 + if (user_account.subscription_type and + user_account.subscription_expires_at and + user_account.subscription_expires_at > datetime.now()): + # 计算当期剩余次数 + remaining = max(0, user_account.balance) + carryover = min(remaining, SUBSCRIPTION_PLANS[plan]["times"]) + + # 更新订阅信息 + user_account.subscription_type = plan + user_account.subscription_expires_at = datetime.now() + SUBSCRIPTION_PLANS[plan]["duration"] + user_account.balance = new_balance + SUBSCRIPTION_PLANS[plan]["times"] + user_account.carryover_balance = carryover + + await user_account_dao.update(db, user_account.id, user_account) + + # 记录使用日志 + account = await user_account_dao.get_by_user_id(db, order.user_id) + await usage_log_dao.add(db, { + "user_id": order.user_id, + "action": "purchase" if order.order_type == "purchase" else "renewal", + "amount": order.amount_times, + "balance_after": account.balance if account else 0, + "related_id": order.id, + "details": {"transaction_id": transaction_id} + }) + + return {"return_code": "SUCCESS"} + else: + logger.error(f"微信支付回调失败: {result}") + return {"return_code": "FAIL", "return_msg": "处理失败"} + + except Exception as e: + logger.error(f"微信支付回调处理异常: {str(e)}") + return {"return_code": "FAIL", "return_msg": "服务器异常"} + + +@router.post("/refund/notify") +async def wxpay_refund_notify(request: Request): + """ + 微信退款异步通知处理 + """ + return await RefundService.handle_refund_notify(request) \ No newline at end of file diff --git a/backend/app/admin/crud/__init__.py b/backend/app/admin/crud/__init__.py new file mode 100755 index 0000000..1d6b4bc --- /dev/null +++ b/backend/app/admin/crud/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from backend.app.admin.crud.file_crud import file_dao +from backend.app.admin.crud.daily_summary_crud import daily_summary_dao +from backend.app.admin.crud.points_crud import points_dao, points_log_dao \ No newline at end of file diff --git a/backend/app/admin/crud/audit_log_crud.py b/backend/app/admin/crud/audit_log_crud.py new file mode 100755 index 0000000..cbe8749 --- /dev/null +++ b/backend/app/admin/crud/audit_log_crud.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime, timedelta, date + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus + +from backend.app.admin.model.audit_log import AuditLog, DailySummary +from backend.app.admin.schema.audit_log import CreateAuditLogParam, AuditLogStatisticsSchema, CreateDailySummaryParam +from backend.app.ai import Image + + +class CRUDAuditLog(CRUDPlus[AuditLog]): + async def create(self, db: AsyncSession, obj: CreateAuditLogParam) -> None: + """ + 创建操作日志 + + :param db: 数据库会话 + :param obj: 创建操作日志参数 + :return: + """ + await self.create_model(db, obj) + + async def get_user_recognition_history(self, db: AsyncSession, user_id: int, page: int = 1, size: int = 20): + """ + 通过用户ID查询历史记录,联合image表,查找api_type='recognition'的记录 + 一个image可能有多个audit_log记录,找出其中called_at最早的 + 支持分页查询 + 返回的数据有image.thumbnail_id, image.created_time, audit_log.dict_level + + :param db: 数据库会话 + :param user_id: 用户ID + :param page: 页码 + :param size: 每页数量 + :return: 查询结果和总数 + """ + # 子查询:找出每个image_id对应的最早called_at记录 + subquery = ( + select( + AuditLog.image_id, + func.min(AuditLog.called_at).label('earliest_called_at') + ) + .where(AuditLog.user_id == user_id, AuditLog.api_type == 'recognition') + .group_by(AuditLog.image_id) + .subquery() + ) + + # 主查询:关联image表和audit_log表,获取所需字段 + stmt = ( + select( + Image.id, + Image.thumbnail_id, + Image.file_id, + Image.created_time, + AuditLog.dict_level + ) + .join(Image, AuditLog.image_id == Image.id) + .join( + subquery, + (AuditLog.image_id == subquery.c.image_id) & + (AuditLog.called_at == subquery.c.earliest_called_at) + ) + .where(AuditLog.user_id == user_id, AuditLog.api_type == 'recognition') + .order_by(AuditLog.called_at.desc(), AuditLog.id.desc()) + .offset((page - 1) * size) + .limit(size) + ) + + result = await db.execute(stmt) + items = result.fetchall() + + # 获取总数 + count_stmt = ( + select(func.count(func.distinct(AuditLog.image_id))) + .where(AuditLog.user_id == user_id, AuditLog.api_type == 'recognition') + ) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + return items, total + + async def get_user_today_recognition_history(self, db: AsyncSession, user_id: int, page: int = 1, size: int = 20): + """ + 通过用户ID查询当天的识别记录,联合image表,查找api_type='recognition'的记录 + 一个image可能有多个audit_log记录,找出其中called_at最早的 + 支持分页查询 + 返回的数据有image.thumbnail_id, image.created_time, audit_log.dict_level + + :param db: 数据库会话 + :param user_id: 用户ID + :param page: 页码 + :param size: 每页数量 + :return: 查询结果和总数 + """ + # 获取当天的开始时间 + today = date.today() + today_start = datetime(today.year, today.month, today.day) + + # 子查询:找出每个image_id对应的最早called_at记录(仅当天) + subquery = ( + select( + AuditLog.image_id, + func.min(AuditLog.called_at).label('earliest_called_at') + ) + .where( + AuditLog.user_id == user_id, + AuditLog.api_type == 'recognition', + AuditLog.called_at >= today_start + ) + .group_by(AuditLog.image_id) + .subquery() + ) + + # 主查询:关联image表和audit_log表,获取所需字段(仅当天) + stmt = ( + select( + Image.id, + Image.thumbnail_id, + Image.file_id, + Image.created_time, + AuditLog.dict_level + ) + .join(Image, AuditLog.image_id == Image.id) + .join( + subquery, + (AuditLog.image_id == subquery.c.image_id) & + (AuditLog.called_at == subquery.c.earliest_called_at) + ) + .where( + AuditLog.user_id == user_id, + AuditLog.api_type == 'recognition', + AuditLog.called_at >= today_start + ) + .order_by(AuditLog.called_at.desc(), AuditLog.id.desc()) + .offset((page - 1) * size) + .limit(size) + ) + + result = await db.execute(stmt) + items = result.fetchall() + + # 获取当天总数 + count_stmt = ( + select(func.count(func.distinct(AuditLog.image_id))) + .where( + AuditLog.user_id == user_id, + AuditLog.api_type == 'recognition', + AuditLog.called_at >= today_start + ) + ) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + return items, total + + async def get_user_recognition_statistics(self, db: AsyncSession, user_id: int): + """ + 统计用户 recognition 类型的使用记录 + 返回历史总量和当天总量 + + :param db: 数据库会话 + :param user_id: 用户ID + :return: (历史总量, 当天总量) + """ + # 获取历史总量 + total_stmt = ( + select(func.count()) + .where(AuditLog.user_id == user_id, AuditLog.api_type == 'recognition') + ) + total_result = await db.execute(total_stmt) + total_count = total_result.scalar() + + # 获取当天总量 + today = date.today() + today_start = datetime(today.year, today.month, today.day) + + today_stmt = ( + select(func.count()) + .where( + AuditLog.user_id == user_id, + AuditLog.api_type == 'recognition', + AuditLog.called_at >= today_start + ) + ) + today_result = await db.execute(today_stmt) + today_count = today_result.scalar() + + # 获取总数 + count_stmt = ( + select(func.count(func.distinct(AuditLog.image_id))) + .where(AuditLog.user_id == user_id, AuditLog.api_type == 'recognition') + ) + count_stmt = ( + select(func.count(func.distinct(AuditLog.image_id))) + .where(AuditLog.user_id == user_id, AuditLog.api_type == 'recognition') + ) + count_result = await db.execute(count_stmt) + image_count = count_result.scalar() + + return total_count, today_count, image_count + + +audit_log_dao: CRUDAuditLog = CRUDAuditLog(AuditLog) \ No newline at end of file diff --git a/backend/app/admin/crud/coupon_crud.py b/backend/app/admin/crud/coupon_crud.py new file mode 100755 index 0000000..95c709a --- /dev/null +++ b/backend/app/admin/crud/coupon_crud.py @@ -0,0 +1,105 @@ +from typing import Optional, List +from sqlalchemy import select, and_, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus +from backend.app.admin.model.coupon import Coupon, CouponUsage +from datetime import datetime + + +class CouponDao(CRUDPlus[Coupon]): + + async def get_by_code(self, db: AsyncSession, code: str) -> Optional[Coupon]: + """ + 根据兑换码获取兑换券 + """ + stmt = select(Coupon).where(Coupon.code == code) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_unused_coupon_by_code(self, db: AsyncSession, code: str) -> Optional[Coupon]: + """ + 根据兑换码获取未使用的兑换券 + """ + stmt = select(Coupon).where( + and_( + Coupon.code == code, + Coupon.is_used == False, + (Coupon.expires_at.is_(None)) | (Coupon.expires_at > datetime.now()) + ) + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def create_coupon(self, db: AsyncSession, coupon_data: dict) -> Coupon: + """ + 创建兑换券 + """ + coupon = Coupon(**coupon_data) + db.add(coupon) + await db.flush() + return coupon + + async def create_coupons(self, db: AsyncSession, coupons_data: List[dict]) -> List[Coupon]: + """ + 批量创建兑换券 + """ + coupons = [Coupon(**data) for data in coupons_data] + db.add_all(coupons) + await db.flush() + return coupons + + async def mark_as_used(self, db: AsyncSession, coupon_id: int, user_id: int, duration: int) -> bool: + """ + 标记兑换券为已使用并创建使用记录 + """ + # 更新兑换券状态 + stmt = update(Coupon).where(Coupon.id == coupon_id).values(is_used=True) + result = await db.execute(stmt) + + if result.rowcount == 0: + return False + + # 创建使用记录 + usage = CouponUsage( + coupon_id=coupon_id, + user_id=user_id, + duration=duration + ) + db.add(usage) + await db.flush() + return True + + async def get_user_coupons(self, db: AsyncSession, user_id: int, limit: int = 100) -> List[CouponUsage]: + """ + 获取用户使用的兑换券记录 + """ + stmt = select(CouponUsage).where( + CouponUsage.user_id == user_id + ).order_by(CouponUsage.used_at.desc()).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + +class CouponUsageDao(CRUDPlus[CouponUsage]): + + async def get_by_user_id(self, db: AsyncSession, user_id: int, limit: int = 100) -> List[CouponUsage]: + """ + 根据用户ID获取兑换记录 + """ + stmt = select(CouponUsage).where( + CouponUsage.user_id == user_id + ).order_by(CouponUsage.used_at.desc()).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + async def get_by_coupon_id(self, db: AsyncSession, coupon_id: int) -> Optional[CouponUsage]: + """ + 根据兑换券ID获取使用记录 + """ + stmt = select(CouponUsage).where(CouponUsage.coupon_id == coupon_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +coupon_dao = CouponDao(Coupon) +coupon_usage_dao = CouponUsageDao(CouponUsage) \ No newline at end of file diff --git a/backend/app/admin/crud/crud_data_scope.py b/backend/app/admin/crud/crud_data_scope.py new file mode 100644 index 0000000..1239fe1 --- /dev/null +++ b/backend/app/admin/crud/crud_data_scope.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Sequence + +from sqlalchemy import Select, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus + +from backend.app.admin.model import DataRule, DataScope +from backend.app.admin.schema.data_scope import CreateDataScopeParam, UpdateDataScopeParam, UpdateDataScopeRuleParam + + +class CRUDDataScope(CRUDPlus[DataScope]): + """数据范围数据库操作类""" + + async def get(self, db: AsyncSession, pk: int) -> DataScope | None: + """ + 获取数据范围详情 + + :param db: 数据库会话 + :param pk: 范围 ID + :return: + """ + return await self.select_model(db, pk) + + async def get_by_name(self, db: AsyncSession, name: str) -> DataScope | None: + """ + 通过名称获取数据范围 + + :param db: 数据库会话 + :param name: 范围名称 + :return: + """ + return await self.select_model_by_column(db, name=name) + + async def get_with_relation(self, db: AsyncSession, pk: int) -> DataScope: + """ + 获取数据范围关联数据 + + :param db: 数据库会话 + :param pk: 范围 ID + :return: + """ + return await self.select_model(db, pk, load_strategies=['rules']) + + async def get_all(self, db: AsyncSession) -> Sequence[DataScope]: + """ + 获取所有数据范围 + + :param db: 数据库会话 + :return: + """ + return await self.select_models(db) + + async def get_list(self, name: str | None, status: int | None) -> Select: + """ + 获取数据范围列表 + + :param name: 范围名称 + :param status: 范围状态 + :return: + """ + filters = {} + + if name is not None: + filters['name__like'] = f'%{name}%' + if status is not None: + filters['status'] = status + + return await self.select_order('id', load_strategies={'rules': 'noload', 'roles': 'noload'}, **filters) + + async def create(self, db: AsyncSession, obj: CreateDataScopeParam) -> None: + """ + 创建数据范围 + + :param db: 数据库会话 + :param obj: 创建数据范围参数 + :return: + """ + await self.create_model(db, obj) + + async def update(self, db: AsyncSession, pk: int, obj: UpdateDataScopeParam) -> int: + """ + 更新数据范围 + + :param db: 数据库会话 + :param pk: 范围 ID + :param obj: 更新数据范围参数 + :return: + """ + return await self.update_model(db, pk, obj) + + async def update_rules(self, db: AsyncSession, pk: int, rule_ids: UpdateDataScopeRuleParam) -> int: + """ + 更新数据范围规则 + + :param db: 数据库会话 + :param pk: 范围 ID + :param rule_ids: 数据规则 ID 列表 + :return: + """ + current_data_scope = await self.get_with_relation(db, pk) + stmt = select(DataRule).where(DataRule.id.in_(rule_ids.rules)) + rules = await db.execute(stmt) + current_data_scope.rules = rules.scalars().all() + return len(current_data_scope.rules) + + async def delete(self, db: AsyncSession, pks: list[int]) -> int: + """ + 批量删除数据范围 + + :param db: 数据库会话 + :param pks: 范围 ID 列表 + :return: + """ + return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks) + + +data_scope_dao: CRUDDataScope = CRUDDataScope(DataScope) diff --git a/backend/app/admin/crud/daily_summary_crud.py b/backend/app/admin/crud/daily_summary_crud.py new file mode 100644 index 0000000..3f6a554 --- /dev/null +++ b/backend/app/admin/crud/daily_summary_crud.py @@ -0,0 +1,46 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus +from sqlalchemy import select, func, desc +from datetime import datetime, date + +from backend.app.admin.model.audit_log import DailySummary +from backend.app.admin.schema.audit_log import DailySummarySchema + + +class DailySummaryCRUD(CRUDPlus[DailySummary]): + """ Daily Summary CRUD """ + + async def get_user_daily_summaries(self, db: AsyncSession, user_id: int, page: int = 1, size: int = 20): + """ + 获取用户的每日汇总记录,按创建时间降序排列 + + :param db: 数据库会话 + :param user_id: 用户ID + :param page: 页码 + :param size: 每页数量 + :return: 查询结果和总数 + """ + # 主查询:获取用户每日汇总记录 + stmt = ( + select(DailySummary) + .where(DailySummary.user_id == user_id) + .order_by(desc(DailySummary.created_time)) + .offset((page - 1) * size) + .limit(size) + ) + + result = await db.execute(stmt) + items = result.scalars().all() + + # 获取总数 + count_stmt = ( + select(func.count()) + .where(DailySummary.user_id == user_id) + ) + total_result = await db.execute(count_stmt) + total = total_result.scalar() + + return items, total + + +daily_summary_dao: DailySummaryCRUD = DailySummaryCRUD(DailySummary) \ No newline at end of file diff --git a/backend/app/admin/crud/dict_crud.py b/backend/app/admin/crud/dict_crud.py new file mode 100755 index 0000000..8f0dc9d --- /dev/null +++ b/backend/app/admin/crud/dict_crud.py @@ -0,0 +1,42 @@ +from typing import Optional, List + +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from backend.app.admin.model.dict import DictionaryEntry, DictionaryMedia + + +class DictCRUD: + async def get_by_word(self, db: AsyncSession, word: str) -> Optional[DictionaryEntry]: + """根据单词查询字典条目""" + query = select(DictionaryEntry).where(DictionaryEntry.word == word) + result = await db.execute(query) + return result.scalars().first() + + async def get_by_id(self, db: AsyncSession, entry_id: int) -> Optional[DictionaryEntry]: + """根据ID获取字典条目""" + return await db.get(DictionaryEntry, entry_id) + + async def get_media_by_dict_id(self, db: AsyncSession, dict_id: int) -> List[DictionaryMedia]: + """根据字典条目ID获取相关媒体文件""" + query = select(DictionaryMedia).where(DictionaryMedia.dict_id == dict_id) + result = await db.execute(query) + return result.scalars().all() + + async def get_media_by_filename(self, db: AsyncSession, filename: str) -> Optional[DictionaryMedia]: + """根据文件名获取媒体文件""" + query = select(DictionaryMedia).where(DictionaryMedia.file_name == filename) + result = await db.execute(query) + return result.scalars().first() + + async def search_words(self, db: AsyncSession, word_pattern: str, limit: int = 10) -> List[DictionaryEntry]: + """模糊搜索单词""" + query = select(DictionaryEntry).where( + DictionaryEntry.word.ilike(f'%{word_pattern}%') + ).limit(limit) + result = await db.execute(query) + return result.scalars().all() + + +dict_dao = DictCRUD() \ No newline at end of file diff --git a/backend/app/admin/crud/feedback_crud.py b/backend/app/admin/crud/feedback_crud.py new file mode 100755 index 0000000..75271c5 --- /dev/null +++ b/backend/app/admin/crud/feedback_crud.py @@ -0,0 +1,119 @@ +from typing import Optional, List + +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.admin.model.feedback import Feedback +from backend.app.admin.schema.feedback import CreateFeedbackParam, UpdateFeedbackParam + + +class FeedbackCRUD: + async def get(self, db: AsyncSession, feedback_id: int) -> Optional[Feedback]: + """根据ID获取反馈""" + return await db.get(Feedback, feedback_id) + + async def get_list( + self, + db: AsyncSession, + user_id: Optional[int] = None, + status: Optional[str] = None, + category: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None + ) -> List[Feedback]: + """获取反馈列表""" + query = select(Feedback).order_by(Feedback.created_at.desc()) + + # 添加过滤条件 + filters = [] + if user_id is not None: + filters.append(Feedback.user_id == user_id) + if status is not None: + filters.append(Feedback.status == status) + if category is not None: + filters.append(Feedback.category == category) + + if filters: + query = query.where(and_(*filters)) + + # 添加分页 + if limit is not None: + query = query.limit(limit) + if offset is not None: + query = query.offset(offset) + + result = await db.execute(query) + return result.scalars().all() + + async def create(self, db: AsyncSession, user_id: int, obj_in: CreateFeedbackParam) -> Feedback: + """创建反馈""" + db_obj = Feedback( + user_id=user_id, + content=obj_in.content, + contact_info=obj_in.contact_info, + category=obj_in.category.value if obj_in.category else None, + metadata_info=obj_in.metadata_info + ) + db.add(db_obj) + await db.flush() + return db_obj + + async def update(self, db: AsyncSession, feedback_id: int, obj_in: UpdateFeedbackParam) -> int: + """更新反馈""" + query = select(Feedback).where(Feedback.id == feedback_id) + result = await db.execute(query) + db_obj = result.scalars().first() + + if db_obj: + update_data = obj_in.model_dump(exclude_unset=True) + # 处理枚举类型 + if 'category' in update_data and update_data['category']: + update_data['category'] = update_data['category'].value + if 'status' in update_data and update_data['status']: + update_data['status'] = update_data['status'].value + + for field, value in update_data.items(): + setattr(db_obj, field, value) + await db.flush() + return 1 + return 0 + + async def delete(self, db: AsyncSession, feedback_id: int) -> int: + """删除反馈""" + query = select(Feedback).where(Feedback.id == feedback_id) + result = await db.execute(query) + db_obj = result.scalars().first() + + if db_obj: + await db.delete(db_obj) + await db.flush() + return 1 + return 0 + + async def count( + self, + db: AsyncSession, + user_id: Optional[int] = None, + status: Optional[str] = None, + category: Optional[str] = None + ) -> int: + """统计反馈数量""" + query = select(func.count(Feedback.id)) + + # 添加过滤条件 + filters = [] + if user_id is not None: + filters.append(Feedback.user_id == user_id) + if status is not None: + filters.append(Feedback.status == status) + if category is not None: + filters.append(Feedback.category == category) + + if filters: + query = query.where(and_(*filters)) + + result = await db.execute(query) + return result.scalar_one() + + +feedback_dao = FeedbackCRUD() \ No newline at end of file diff --git a/backend/app/admin/crud/file_crud.py b/backend/app/admin/crud/file_crud.py new file mode 100755 index 0000000..584b89a --- /dev/null +++ b/backend/app/admin/crud/file_crud.py @@ -0,0 +1,60 @@ +from typing import Optional + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.admin.model.file import File +from backend.app.admin.schema.file import AddFileParam, UpdateFileParam + + +class FileCRUD: + async def get(self, db: AsyncSession, file_id: int) -> Optional[File]: + """根据ID获取文件""" + return await db.get(File, file_id) + + async def get_by_hash(self, db: AsyncSession, file_hash: str) -> Optional[File]: + """根据哈希值获取文件""" + query = select(File).where(File.file_hash == file_hash) + result = await db.execute(query) + return result.scalars().first() + + async def create(self, db: AsyncSession, obj_in: AddFileParam) -> File: + """创建文件记录""" + db_obj = File(**obj_in.model_dump()) + db.add(db_obj) + await db.flush() + return db_obj + + async def update(self, db: AsyncSession, file_id: int, obj_in: UpdateFileParam) -> int: + """更新文件记录""" + query = select(File).where(File.id == file_id) + result = await db.execute(query) + db_obj = result.scalars().first() + + if db_obj: + for field, value in obj_in.model_dump(exclude_unset=True).items(): + setattr(db_obj, field, value) + await db.flush() + return 1 + return 0 + + async def delete(self, db: AsyncSession, file_id: int) -> int: + """删除文件记录""" + query = select(File).where(File.id == file_id) + result = await db.execute(query) + db_obj = result.scalars().first() + + if db_obj: + await db.delete(db_obj) + await db.flush() + return 1 + return 0 + + async def count(self, db: AsyncSession) -> int: + """统计文件数量""" + query = select(func.count(File.id)) + result = await db.execute(query) + return result.scalar_one() + + +file_dao = FileCRUD() \ No newline at end of file diff --git a/backend/app/admin/crud/freeze_log_crud.py b/backend/app/admin/crud/freeze_log_crud.py new file mode 100755 index 0000000..e865174 --- /dev/null +++ b/backend/app/admin/crud/freeze_log_crud.py @@ -0,0 +1,38 @@ +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus +from backend.app.admin.model.order import FreezeLog + + +class FreezeLogDao(CRUDPlus[FreezeLog]): + + async def get_by_id(self, db: AsyncSession, freeze_id: int) -> Optional[FreezeLog]: + """ + 根据ID获取冻结记录 + """ + stmt = select(FreezeLog).where(FreezeLog.id == freeze_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_order_id(self, db: AsyncSession, order_id: int) -> Optional[FreezeLog]: + """ + 根据订单ID获取冻结记录 + """ + stmt = select(FreezeLog).where(FreezeLog.order_id == order_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_pending_by_user(self, db: AsyncSession, user_id: int) -> list[FreezeLog]: + """ + 获取用户所有待处理的冻结记录 + """ + stmt = select(FreezeLog).where( + FreezeLog.user_id == user_id, + FreezeLog.status == "pending" + ) + result = await db.execute(stmt) + return result.scalars().all() + + +freeze_log_dao = FreezeLogDao(FreezeLog) \ No newline at end of file diff --git a/backend/app/admin/crud/notification_crud.py b/backend/app/admin/crud/notification_crud.py new file mode 100755 index 0000000..6cfdaf9 --- /dev/null +++ b/backend/app/admin/crud/notification_crud.py @@ -0,0 +1,135 @@ +from typing import Optional, List +from sqlalchemy import select, and_, update, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus +from backend.app.admin.model.notification import Notification, UserNotification +from datetime import datetime + + +class NotificationDao(CRUDPlus[Notification]): + + async def create_notification(self, db: AsyncSession, notification_data: dict) -> Notification: + """ + 创建消息通知 + """ + notification = Notification(**notification_data) + db.add(notification) + await db.flush() + return notification + + async def get_active_notifications(self, db: AsyncSession, limit: int = 100) -> List[Notification]: + """ + 获取激活的通知列表 + """ + stmt = select(Notification).where( + Notification.is_active == True + ).order_by(Notification.created_at.desc()).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + async def get_notification_by_id(self, db: AsyncSession, notification_id: int) -> Optional[Notification]: + """ + 根据ID获取通知 + """ + stmt = select(Notification).where(Notification.id == notification_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +class UserNotificationDao(CRUDPlus[UserNotification]): + + async def create_user_notification(self, db: AsyncSession, user_notification_data: dict) -> UserNotification: + """ + 创建用户通知关联 + """ + user_notification = UserNotification(**user_notification_data) + db.add(user_notification) + await db.flush() + return user_notification + + async def create_user_notifications(self, db: AsyncSession, user_notifications_data: List[dict]) -> List[UserNotification]: + """ + 批量创建用户通知关联 + """ + user_notifications = [UserNotification(**data) for data in user_notifications_data] + db.add_all(user_notifications) + await db.flush() + return user_notifications + + async def get_user_notifications(self, db: AsyncSession, user_id: int, limit: int = 100) -> List[UserNotification]: + """ + 获取用户的通知列表 + """ + stmt = select(UserNotification).where( + UserNotification.user_id == user_id + ).order_by(UserNotification.received_at.desc()).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + async def get_unread_notifications(self, db: AsyncSession, user_id: int, limit: int = 100) -> List[UserNotification]: + """ + 获取用户未读通知列表 + """ + stmt = select(UserNotification).where( + and_( + UserNotification.user_id == user_id, + UserNotification.is_read == False + ) + ).order_by(UserNotification.received_at.desc()).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + async def mark_as_read(self, db: AsyncSession, user_notification_id: int) -> bool: + """ + 标记通知为已读 + """ + stmt = update(UserNotification).where( + UserNotification.id == user_notification_id + ).values( + is_read=True, + read_at=datetime.now() + ) + result = await db.execute(stmt) + return result.rowcount > 0 + + async def mark_multiple_as_read(self, db: AsyncSession, user_id: int, notification_ids: List[int]) -> int: + """ + 批量标记通知为已读 + """ + stmt = update(UserNotification).where( + and_( + UserNotification.user_id == user_id, + UserNotification.notification_id.in_(notification_ids), + UserNotification.is_read == False + ) + ).values( + is_read=True, + read_at=datetime.now() + ) + result = await db.execute(stmt) + return result.rowcount + + async def get_unread_count(self, db: AsyncSession, user_id: int) -> int: + """ + 获取用户未读通知数量 + """ + stmt = select(func.count(UserNotification.id)).where( + and_( + UserNotification.user_id == user_id, + UserNotification.is_read == False + ) + ) + result = await db.execute(stmt) + return result.scalar() or 0 + + async def get_user_notification_by_id(self, db: AsyncSession, user_notification_id: int) -> Optional[UserNotification]: + """ + 根据ID获取用户通知关联记录 + """ + stmt = select(UserNotification).where(UserNotification.id == user_notification_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +notification_dao = NotificationDao(Notification) +user_notification_dao = UserNotificationDao(UserNotification) \ No newline at end of file diff --git a/backend/app/admin/crud/order_crud.py b/backend/app/admin/crud/order_crud.py new file mode 100755 index 0000000..12bfc8e --- /dev/null +++ b/backend/app/admin/crud/order_crud.py @@ -0,0 +1,94 @@ +from typing import Optional, List +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus +from backend.app.admin.model.order import Order + + +class OrderDao(CRUDPlus[Order]): + + async def get_by_id(self, db: AsyncSession, order_id: int) -> Optional[Order]: + """ + 根据ID获取订单 + """ + stmt = select(Order).where(Order.id == order_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_user_id(self, db: AsyncSession, user_id: int, limit: int = 100) -> List[Order]: + """ + 根据用户ID获取订单列表 + """ + stmt = select(Order).where( + Order.user_id == user_id + ).order_by(Order.created_at.desc()).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + async def get_pending_orders(self, db: AsyncSession, user_id: int) -> List[Order]: + """ + 获取用户所有待处理订单 + """ + stmt = select(Order).where( + and_( + Order.user_id == user_id, + Order.status == "pending" + ) + ) + result = await db.execute(stmt) + return result.scalars().all() + + async def get_completed_orders(self, db: AsyncSession, user_id: int, limit: int = 50) -> List[Order]: + """ + 获取用户已完成的订单 + """ + stmt = select(Order).where( + and_( + Order.user_id == user_id, + Order.status == "completed" + ) + ).order_by(Order.created_at.desc()).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + async def get_order_by_payment_id(self, db: AsyncSession, payment_id: str) -> Optional[Order]: + """ + 根据支付ID获取订单 + """ + stmt = select(Order).where(Order.payment_id == payment_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_order_by_transaction_id(self, db: AsyncSession, transaction_id: str) -> Optional[Order]: + """ + 根据交易ID获取订单 + """ + stmt = select(Order).where(Order.transaction_id == transaction_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def update_order_status(self, db: AsyncSession, order_id: int, status: str) -> bool: + """ + 更新订单状态 + """ + from sqlalchemy import update + + stmt = update(Order).where(Order.id == order_id).values(status=status) + result = await db.execute(stmt) + return result.rowcount > 0 + + async def get_subscription_orders(self, db: AsyncSession, user_id: int) -> List[Order]: + """ + 获取用户所有订阅订单 + """ + stmt = select(Order).where( + and_( + Order.user_id == user_id, + Order.order_type == "subscription" + ) + ).order_by(Order.created_at.desc()) + result = await db.execute(stmt) + return result.scalars().all() + + +order_dao = OrderDao(Order) \ No newline at end of file diff --git a/backend/app/admin/crud/points_crud.py b/backend/app/admin/crud/points_crud.py new file mode 100644 index 0000000..f72a348 --- /dev/null +++ b/backend/app/admin/crud/points_crud.py @@ -0,0 +1,103 @@ +from datetime import datetime +from typing import Optional, Dict, Any +from sqlalchemy import select, update, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus +from backend.app.admin.model.points import Points, PointsLog + + +class PointsDao(CRUDPlus[Points]): + async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Optional[Points]: + """ + 根据用户ID获取积分账户信息 + """ + stmt = select(Points).where(Points.user_id == user_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def create_user_points(self, db: AsyncSession, user_id: int) -> Points: + """ + 为用户创建积分账户 + """ + points = Points(user_id=user_id) + db.add(points) + await db.flush() + return points + + async def add_points_atomic(self, db: AsyncSession, user_id: int, amount: int, extend_expiration: bool = False) -> bool: + """ + 原子性增加用户积分 + """ + # 先确保用户有积分账户 + points_account = await self.get_by_user_id(db, user_id) + if not points_account: + points_account = await self.create_user_points(db, user_id) + + # 准备更新值 + update_values = { + "balance": Points.balance + amount, + "total_earned": Points.total_earned + amount + } + + # 如果需要延期,则更新过期时间 + if extend_expiration: + update_values["expired_time"] = datetime.now() + timedelta(days=30) + + stmt = update(Points).where( + Points.user_id == user_id + ).values(**update_values) + result = await db.execute(stmt) + return result.rowcount > 0 + + async def deduct_points_atomic(self, db: AsyncSession, user_id: int, amount: int) -> bool: + """ + 原子性扣减用户积分(确保不超扣) + """ + stmt = update(Points).where( + Points.user_id == user_id, + Points.balance >= amount + ).values( + balance=Points.balance - amount, + total_spent=Points.total_spent + amount + ) + result = await db.execute(stmt) + return result.rowcount > 0 + + async def get_balance(self, db: AsyncSession, user_id: int) -> int: + """ + 获取用户积分余额 + """ + points_account = await self.get_by_user_id(db, user_id) + if not points_account: + return 0 + return points_account.balance + + async def check_and_clear_expired_points(self, db: AsyncSession, user_id: int) -> bool: + """ + 检查并清空过期积分 + """ + stmt = update(Points).where( + Points.user_id == user_id, + Points.expired_time < datetime.now(), + Points.balance > 0 + ).values( + balance=0, + total_spent=Points.total_spent + Points.balance + ) + result = await db.execute(stmt) + return result.rowcount > 0 + + +class PointsLogDao(CRUDPlus[PointsLog]): + async def add_log(self, db: AsyncSession, log_data: Dict[str, Any]) -> PointsLog: + """ + 添加积分变动日志 + """ + log = PointsLog(**log_data) + db.add(log) + await db.flush() + return log + + +points_dao = PointsDao(Points) +points_log_dao = PointsLogDao(PointsLog) \ No newline at end of file diff --git a/backend/app/admin/crud/usage_log_crud.py b/backend/app/admin/crud/usage_log_crud.py new file mode 100755 index 0000000..f346e27 --- /dev/null +++ b/backend/app/admin/crud/usage_log_crud.py @@ -0,0 +1,61 @@ +from typing import Optional, List, Dict, Any +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.admin.model.order import UsageLog +from sqlalchemy_crud_plus import CRUDPlus + + +class UsageLogDao(CRUDPlus[UsageLog]): + + async def get_by_id(self, db: AsyncSession, log_id: int) -> Optional[UsageLog]: + """ + 根据ID获取使用日志 + """ + stmt = select(UsageLog).where(UsageLog.id == log_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_user_id(self, db: AsyncSession, user_id: int, limit: int = 100) -> List[UsageLog]: + """ + 根据用户ID获取使用日志列表 + """ + stmt = select(UsageLog).where( + UsageLog.user_id == user_id + ).order_by(UsageLog.created_at.desc()).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + async def get_by_action(self, db: AsyncSession, user_id: int, action: str, limit: int = 50) -> List[UsageLog]: + """ + 根据动作类型获取使用日志 + """ + stmt = select(UsageLog).where( + and_( + UsageLog.user_id == user_id, + UsageLog.action == action + ) + ).order_by(UsageLog.created_at.desc()).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + async def get_balance_history(self, db: AsyncSession, user_id: int, limit: int = 100) -> List[UsageLog]: + """ + 获取用户余额变动历史 + """ + stmt = select(UsageLog).where( + UsageLog.user_id == user_id + ).order_by(UsageLog.created_at.desc()).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + async def add_log(self, db: AsyncSession, log_data: Dict[str, Any]) -> UsageLog: + """ + 添加使用日志 + """ + log = UsageLog(**log_data) + db.add(log) + await db.flush() + return log + + +usage_log_dao = UsageLogDao(UsageLog) \ No newline at end of file diff --git a/backend/app/admin/crud/user_account_crud.py b/backend/app/admin/crud/user_account_crud.py new file mode 100755 index 0000000..e45bdd0 --- /dev/null +++ b/backend/app/admin/crud/user_account_crud.py @@ -0,0 +1,105 @@ +from datetime import datetime, timedelta +from typing import Optional +from sqlalchemy import select, update, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus +from backend.app.admin.model.order import UserAccount, FreezeLog + + +class UserAccountDao(CRUDPlus[UserAccount]): + + async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Optional[UserAccount]: + """ + 根据用户ID获取账户信息 + """ + stmt = select(UserAccount).where(UserAccount.user_id == user_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_id(self, db: AsyncSession, account_id: int) -> Optional[UserAccount]: + """ + 根据账户ID获取账户信息 + """ + stmt = select(UserAccount).where(UserAccount.id == account_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def create_new_user_account(self, db: AsyncSession, user_id: int) -> UserAccount: + """ + 为新用户创建账户(包含免费试用) + """ + # 设置免费试用期(3天) + trial_expires_at = datetime.now() + timedelta(days=3) + + account = UserAccount( + user_id=user_id, + balance=30, # 初始30次免费次数 + free_trial_balance=30, + free_trial_expires_at=trial_expires_at, + free_trial_used=True # 标记为已使用(因为已经给了30次) + ) + db.add(account) + await db.flush() + return account + + async def update_balance_atomic(self, db: AsyncSession, user_id: int, amount: int) -> bool: + """ + 原子性更新用户余额 + """ + stmt = update(UserAccount).where( + UserAccount.user_id == user_id + ).values( + balance=UserAccount.balance + amount + ) + result = await db.execute(stmt) + return result.rowcount > 0 + + async def deduct_balance_atomic(self, db: AsyncSession, user_id: int, amount: int) -> bool: + """ + 原子性扣减用户余额(确保不超扣) + """ + stmt = update(UserAccount).where( + UserAccount.user_id == user_id, + UserAccount.balance >= amount + ).values( + balance=UserAccount.balance - amount + ) + result = await db.execute(stmt) + return result.rowcount > 0 + + async def get_frozen_balance(self, db: AsyncSession, user_id: int) -> int: + """ + 获取用户被冻结的次数 + """ + + stmt = select(func.sum(FreezeLog.amount)).where( + FreezeLog.user_id == user_id, + FreezeLog.status == "pending" + ) + result = await db.execute(stmt) + return result.scalar() or 0 + + async def get_available_balance(self, db: AsyncSession, user_id: int) -> int: + """ + 获取用户可用余额(总余额减去冻结余额) + """ + account = await self.get_by_user_id(db, user_id) + if not account: + return 0 + + frozen_balance = await self.get_frozen_balance(db, user_id) + return max(0, account.balance - frozen_balance) + + async def check_free_trial_valid(self, db: AsyncSession, user_id: int) -> bool: + """ + 检查用户免费试用是否仍然有效 + """ + account = await self.get_by_user_id(db, user_id) + if not account or not account.free_trial_expires_at: + return False + + return (account.free_trial_expires_at > datetime.now() and + account.free_trial_balance > 0) + + +user_account_dao = UserAccountDao(UserAccount) \ No newline at end of file diff --git a/backend/app/admin/crud/wx_user_crud.py b/backend/app/admin/crud/wx_user_crud.py new file mode 100755 index 0000000..2db712a --- /dev/null +++ b/backend/app/admin/crud/wx_user_crud.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime + +import bcrypt +from sqlalchemy import select, update, desc, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import Select +from sqlalchemy_crud_plus import CRUDPlus + +from backend.app.admin.model import WxUser +from backend.app.admin.schema.user import RegisterUserParam, UpdateUserParam, AvatarParam +from backend.common.security.jwt import get_hash_password +from backend.utils.wx_pay import wx_pay_utils + + +class WxUserCRUD(CRUDPlus[WxUser]): + async def get(self, db: AsyncSession, user_id: int) -> WxUser | None: + """ + 获取用户 + + :param db: + :param user_id: + :return: + """ + return await self.select_model(db, user_id) + + async def get_by_username(self, db: AsyncSession, username: str) -> WxUser | None: + """ + 通过 username 获取用户 + + :param db: + :param username: + :return: + """ + return await self.select_model_by_column(db, username=username) + + async def get_by_openid(self, db: AsyncSession, openid: str) -> WxUser | None: + """ + 通过 username 获取用户 + + :param db: + :param openid: + :return: + """ + return await self.select_model_by_column(db, openid=openid) + + async def add(self, db: AsyncSession, new_user: WxUser) -> None: + """ + 通过 openid 添加用户 + + :param db: + :param openid: + :return: + """ + db.add(new_user) + + async def update_session_key(self, db: AsyncSession, input_user: int, session_key: str) -> int: + """ + 更新用户头像 + + :param db: + :param input_user: + :param avatar: + :return: + """ + return await self.update_model(db, input_user, {'session_key': session_key}) + + async def update_user_profile(self, db: AsyncSession, user_id: int, profile: dict) -> int: + """ + 更新用户资料并自动更新updated_time字段 + + :param db: 数据库会话 + :param user_id: 用户ID + :param profile: 用户资料 + :return: 更新的记录数 + """ + return await self.update_model(db, user_id, {'profile': profile}) + + async def update_login_time(self, db: AsyncSession, username: str, login_time: datetime) -> int: + user = await db.execute( + update(self.model).where(self.model.username == username).values(last_login_time=login_time) + ) + return user.rowcount + + async def create(self, db: AsyncSession, obj: RegisterUserParam) -> None: + """ + 创建用户 + + :param db: + :param obj: + :return: + """ + salt = bcrypt.gensalt() + obj.password = get_hash_password(obj.password, salt) + dict_obj = obj.model_dump() + dict_obj.update({'salt': salt}) + new_user = self.model(**dict_obj) + db.add(new_user) + + async def update_userinfo(self, db: AsyncSession, input_user: int, obj: UpdateUserParam) -> int: + """ + 更新用户信息 + + :param db: + :param input_user: + :param obj: + :return: + """ + return await self.update_model(db, input_user, obj) + + async def update_avatar(self, db: AsyncSession, input_user: int, avatar: AvatarParam) -> int: + """ + 更新用户头像 + + :param db: + :param input_user: + :param avatar: + :return: + """ + return await self.update_model(db, input_user, {'avatar': avatar.url}) + + async def delete(self, db: AsyncSession, user_id: int) -> int: + """ + 删除用户 + + :param db: + :param user_id: + :return: + """ + return await self.delete_model(db, user_id) + + async def check_email(self, db: AsyncSession, email: str) -> WxUser: + """ + 检查邮箱是否存在 + + :param db: + :param email: + :return: + """ + return await self.select_model_by_column(db, email=email) + + async def reset_password(self, db: AsyncSession, pk: int, new_pwd: str) -> int: + """ + 重置用户密码 + + :param db: + :param pk: + :param new_pwd: + :return: + """ + return await self.update_model(db, pk, {'password': new_pwd}) + + async def get_list(self, username: str = None, phone: str = None, status: int = None) -> Select: + """ + 获取用户列表 + + :param username: + :param phone: + :param status: + :return: + """ + stmt = select(self.model).order_by(desc(self.model.join_time)) + + filters = [] + if username: + filters.append(self.model.username.like(f'%{username}%')) + if phone: + filters.append(self.model.phone.like(f'%{phone}%')) + if status is not None: + filters.append(self.model.status == status) + + if filters: + stmt = stmt.where(and_(*filters)) + + return stmt + + async def get_with_relation( + self, db: AsyncSession, *, user_id: int | None = None, openid: str | None = None + ) -> WxUser | None: + """ + 获取用户关联信息 + + :param db: 数据库会话 + :param user_id: 用户 ID + :param username: 用户名 + :return: + """ + filters = {} + + if user_id: + filters['id'] = user_id + if openid: + filters['openid'] = openid + + return await self.select_model_by_column( + db, + **filters, + ) + +wx_user_dao: WxUserCRUD = WxUserCRUD(WxUser) diff --git a/backend/app/admin/model/__init__.py b/backend/app/admin/model/__init__.py new file mode 100755 index 0000000..d889f0e --- /dev/null +++ b/backend/app/admin/model/__init__.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from backend.common.model import MappedBase # noqa: I +from backend.app.admin.model.wx_user import WxUser +from backend.app.admin.model.audit_log import AuditLog, DailySummary +from backend.app.admin.model.file import File +from backend.app.admin.model.dict import DictionaryEntry, DictionaryMedia +from backend.app.admin.model.order import Order, UserAccount, FreezeLog, UsageLog +from backend.app.admin.model.coupon import Coupon, CouponUsage +from backend.app.admin.model.notification import Notification, UserNotification +from backend.app.admin.model.points import Points, PointsLog +from backend.app.ai.model import Image \ No newline at end of file diff --git a/backend/app/admin/model/audit_log.py b/backend/app/admin/model/audit_log.py new file mode 100755 index 0000000..1c26c3d --- /dev/null +++ b/backend/app/admin/model/audit_log.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime +from typing import Optional, List + +from sqlalchemy import Integer, BigInteger, Text, String, Numeric, Float, DateTime, ForeignKey, Index +from sqlalchemy.dialects.postgresql import JSONB, ARRAY +from sqlalchemy.orm import Mapped, mapped_column + +from backend.common.model import snowflake_id_key, Base + + +class AuditLog(Base): + __tablename__ = 'audit_log' + + id: Mapped[snowflake_id_key] = mapped_column(init=False, primary_key=True) + api_type: Mapped[str] = mapped_column(String(20), nullable=False, comment="API类型: recognition embedding assessment") + model_name: Mapped[str] = mapped_column(String(50), nullable=False, comment="模型名称") + request_data: Mapped[Optional[dict]] = mapped_column(JSONB, comment="请求数据") + response_data: Mapped[Optional[dict]] = mapped_column(JSONB, comment="响应数据") + token_usage: Mapped[Optional[dict]] = mapped_column(JSONB, comment="消耗的token数量") + cost: Mapped[Optional[float]] = mapped_column(Numeric(10, 5), comment="API调用成本") + duration: Mapped[Optional[float]] = mapped_column(Float, comment="调用耗时(秒)") + status_code: Mapped[Optional[int]] = mapped_column(Integer, comment="HTTP状态码") + image_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('image.id'), comment="关联的图片ID") + user_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('wx_user.id'), comment="调用用户ID") + dict_level: Mapped[Optional[str]] = mapped_column(String(20), comment="dict level") + api_version: Mapped[Optional[str]] = mapped_column(String(20), comment="API版本") + error_message: Mapped[Optional[str]] = mapped_column(Text, default=None, comment="错误信息") + called_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="调用时间") + + # 索引优化 + __table_args__ = ( + Index('idx_audit_logs_image_id', 'image_id'), + # 为用户历史记录查询优化的索引 + Index('idx_audit_log_user_api_called', 'user_id', 'api_type', 'called_at'), + ) + + +class DailySummary(Base): + __tablename__ = 'daily_summary' + + id: Mapped[snowflake_id_key] = mapped_column(init=False, primary_key=True) + user_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('wx_user.id'), comment="调用用户ID") + image_ids: Mapped[List[str]] = mapped_column(ARRAY(Text), default=None, comment="图片ID列表") + thumbnail_ids: Mapped[List[str]] = mapped_column(ARRAY(Text), default=None, comment="图片缩略图列表") + summary_time: Mapped[datetime] = mapped_column(DateTime, default=None, comment="总结的时间") + + # 索引优化 + __table_args__ = ( + # 为用户历史记录查询优化的索引 + Index('idx_daily_summary_api_called', 'user_id', 'summary_time'), + ) \ No newline at end of file diff --git a/backend/app/admin/model/coupon.py b/backend/app/admin/model/coupon.py new file mode 100755 index 0000000..7a2eb74 --- /dev/null +++ b/backend/app/admin/model/coupon.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime +from typing import Optional + +from sqlalchemy import String, Column, BigInteger, ForeignKey, Boolean, DateTime, Index, Text +from sqlalchemy.orm import Mapped, mapped_column + +from backend.common.model import snowflake_id_key, Base + + +class Coupon(Base): + __tablename__ = 'coupon' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + code: Mapped[str] = mapped_column(String(32), unique=True, nullable=False, comment='兑换码') + duration: Mapped[int] = mapped_column(BigInteger, nullable=False, comment='兑换时长(分钟)') + is_used: Mapped[bool] = mapped_column(Boolean, default=False, comment='是否已使用') + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment='创建时间') + expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None, comment='过期时间') + created_by: Mapped[Optional[int]] = mapped_column(BigInteger, default=None, comment='创建者ID') + + __table_args__ = ( + Index('idx_coupon_code', 'code'), + Index('idx_coupon_is_used', 'is_used'), + {'comment': '兑换券表'} + ) + + +class CouponUsage(Base): + __tablename__ = 'coupon_usage' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + coupon_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('coupon.id'), nullable=False, comment='兑换券ID') + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='使用者ID') + duration: Mapped[int] = mapped_column(BigInteger, nullable=False, comment='兑换时长(分钟)') + used_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment='使用时间') + + __table_args__ = ( + Index('idx_coupon_usage_user', 'user_id'), + Index('idx_coupon_usage_coupon', 'coupon_id'), + {'comment': '兑换券使用记录表'} + ) \ No newline at end of file diff --git a/backend/app/admin/model/dict.py b/backend/app/admin/model/dict.py new file mode 100755 index 0000000..36cc853 --- /dev/null +++ b/backend/app/admin/model/dict.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Optional + +from sqlalchemy import String, Column, LargeBinary, ForeignKey, BigInteger, Index, func, JSON, Text, Numeric +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.admin.schema.dict import WordMetaData +from backend.app.admin.schema.pydantic_type import PydanticType +from backend.common.model import snowflake_id_key, DataClassBase + + +class DictionaryEntry(DataClassBase): + __tablename__ = 'dict_entry' + + id: Mapped[int] = mapped_column(primary_key=True, init=True, autoincrement=True) + word: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + definition: Mapped[Optional[str]] = mapped_column(Text, default=None) + details: Mapped[Optional[WordMetaData]] = mapped_column(PydanticType(pydantic_type=WordMetaData), default=None) # 其他可能的字段(根据实际需求添加) + + __table_args__ = ( + Index('idx_dict_word', word), + ) + + + +class DictionaryMedia(DataClassBase): + __tablename__ = 'dict_media' + + id: Mapped[int] = mapped_column(primary_key=True, init=True, autoincrement=True) + file_name: Mapped[str] = mapped_column(String(255), nullable=False) + file_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'audio', 'image' + dict_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("dict_entry.id"), default=None) + file_data: Mapped[Optional[bytes]] = mapped_column(LargeBinary, default=None) + file_hash: Mapped[Optional[str]] = mapped_column(String(64), default=None) + details: Mapped[Optional[dict]] = mapped_column(JSONB(astext_type=Text()), default=None, comment="其他信息") # 其他信息 + + __table_args__ = ( + Index('idx_media_filename', file_name), + Index('idx_media_dict_id', dict_id), + ) diff --git a/backend/app/admin/model/feedback.py b/backend/app/admin/model/feedback.py new file mode 100755 index 0000000..a243196 --- /dev/null +++ b/backend/app/admin/model/feedback.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime +from typing import Optional, List + +from sqlalchemy import String, Text, DateTime, ForeignKey, Index, BigInteger +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.common.model import snowflake_id_key, Base + + +class Feedback(Base): + __tablename__ = 'feedback' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='用户ID') + content: Mapped[str] = mapped_column(Text, nullable=False, comment='反馈内容') + contact_info: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment='联系方式') + category: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment='反馈分类') + status: Mapped[str] = mapped_column(String(20), default='pending', comment='处理状态: pending, processing, resolved') + + # 索引优化 + __table_args__ = ( + Index('idx_feedback_user_id', 'user_id'), + Index('idx_feedback_status', 'status'), + Index('idx_feedback_category', 'category'), + Index('idx_feedback_created_at', 'created_time'), + ) diff --git a/backend/app/admin/model/file.py b/backend/app/admin/model/file.py new file mode 100755 index 0000000..01b52f8 --- /dev/null +++ b/backend/app/admin/model/file.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Optional + +from sqlalchemy import BigInteger, Text, String, Index, DateTime, LargeBinary +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import mapped_column, Mapped + +from backend.common.model import snowflake_id_key, Base + + +class File(Base): + __tablename__ = 'file' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + file_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False) # SHA256哈希 + file_name: Mapped[str] = mapped_column(String(255), nullable=False) # 原始文件名 + content_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # MIME类型 + file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) # 文件大小(字节) + storage_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # 存储路径(非数据库存储时使用) + file_data: Mapped[Optional[bytes]] = mapped_column(LargeBinary, default=None, nullable=True) # 文件二进制数据(数据库存储时使用) + storage_type: Mapped[str] = mapped_column(String(20), nullable=False, default='database') # 存储类型: database, local, s3 + metadata_info: Mapped[Optional[dict]] = mapped_column(JSONB(astext_type=Text()), default=None, comment="元数据信息") + + # 表参数 - 包含所有必要的约束 + __table_args__ = ( + Index('idx_file_hash', file_hash), + ) diff --git a/backend/app/admin/model/notification.py b/backend/app/admin/model/notification.py new file mode 100755 index 0000000..1592e43 --- /dev/null +++ b/backend/app/admin/model/notification.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime +from typing import Optional + +from sqlalchemy import String, Column, BigInteger, ForeignKey, Boolean, DateTime, Index, Text +from sqlalchemy.orm import Mapped, mapped_column + +from backend.common.model import snowflake_id_key, Base + + +class Notification(Base): + __tablename__ = 'notification' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + title: Mapped[str] = mapped_column(String(255), nullable=False, comment='通知标题') + content: Mapped[str] = mapped_column(Text, nullable=False, comment='通知内容') + image_url: Mapped[Optional[str]] = mapped_column(String(512), default=None, comment='图片URL(预留)') + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment='创建时间') + created_by: Mapped[Optional[int]] = mapped_column(BigInteger, default=None, comment='创建者ID') + is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment='是否激活') + + __table_args__ = ( + Index('idx_notification_created', 'created_at'), + Index('idx_notification_active', 'is_active'), + {'comment': '消息通知表'} + ) + + +class UserNotification(Base): + __tablename__ = 'user_notification' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + notification_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('notification.id'), nullable=False, comment='通知ID') + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='用户ID') + is_read: Mapped[bool] = mapped_column(Boolean, default=False, comment='是否已读') + read_at: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None, comment='阅读时间') + received_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment='接收时间') + + __table_args__ = ( + Index('idx_user_notification_user', 'user_id'), + Index('idx_user_notification_notification', 'notification_id'), + Index('idx_user_notification_read', 'is_read'), + {'comment': '用户通知关联表'} + ) \ No newline at end of file diff --git a/backend/app/admin/model/order.py b/backend/app/admin/model/order.py new file mode 100755 index 0000000..3a0c7e8 --- /dev/null +++ b/backend/app/admin/model/order.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime +from typing import Optional + +from sqlalchemy import String, Column, BigInteger, ForeignKey, Boolean, DateTime, Index, func, JSON, Text, Numeric +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from backend.common.model import snowflake_id_key, Base + + +class Order(Base): + __tablename__ = 'order' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False) + order_type: Mapped[str] = mapped_column(String(32), comment='类型:purchase/subscription/extra') + payment_id: Mapped[Optional[str]] = mapped_column(String(64), comment='微信支付ID') + transaction_id: Mapped[Optional[str]] = mapped_column(String(64), comment='微信交易号') + amount_cents: Mapped[int] = mapped_column(BigInteger, comment='金额(分)') + amount_times: Mapped[int] = mapped_column(BigInteger, comment='实际获得次数') + status: Mapped[str] = mapped_column(String(16), default='pending', comment='订单状态') + expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None, comment='过期时间(用于订阅)') + processed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None, comment='处理时间(用于幂等性)') + + __table_args__ = ( + Index('idx_order_user_status', 'user_id', 'status'), + Index('idx_order_processed', 'processed_at'), + ) + +class UserAccount(Base): + __tablename__ = 'user_account' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), unique=True, nullable=False, comment='关联的用户ID') + balance: Mapped[int] = mapped_column(BigInteger, default=0, comment='当前可用次数') + total_purchased: Mapped[int] = mapped_column(BigInteger, default=0, comment='累计购买次数') + subscription_type: Mapped[Optional[str]] = mapped_column(String(32), default=None, comment='订阅类型:monthly/quarterly/half_yearly/yearly') + subscription_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None, comment='订阅到期时间') + carryover_balance: Mapped[int] = mapped_column(BigInteger, default=0, comment='上期未使用的次数') + # 新用户免费次数相关 + free_trial_balance: Mapped[int] = mapped_column(BigInteger, default=30, comment='新用户免费试用次数') + free_trial_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, default=None, comment='免费试用期结束时间') + free_trial_used: Mapped[bool] = mapped_column(Boolean, default=False, comment='是否已使用免费试用') + +class FreezeLog(Base): + __tablename__ = 'freeze_log' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False) + order_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('order.id'), nullable=False) + amount: Mapped[int] = mapped_column(BigInteger, comment='冻结次数') + reason: Mapped[Optional[str]] = mapped_column(Text, comment='冻结原因') + status: Mapped[str] = mapped_column(String(16), default='pending', comment='状态:pending/confirmed/cancelled') + + __table_args__ = ( + Index('idx_freeze_user_status', 'user_id', 'status'), + {'comment': '次数冻结记录表'} + ) + + +class UsageLog(Base): + __tablename__ = 'usage_log' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='用户ID') + action: Mapped[str] = mapped_column(String(32), comment='动作:purchase/renewal/use/carryover/share/ad/freeze/unfreeze/refund') + amount: Mapped[int] = mapped_column(BigInteger, comment='变动数量') + balance_after: Mapped[int] = mapped_column(BigInteger, comment='变动后余额') + related_id: Mapped[Optional[int]] = mapped_column(BigInteger, default=None, comment='关联ID,如订单ID、冻结记录ID') + details: Mapped[Optional[dict]] = mapped_column(JSONB, default=None, comment='附加信息') + + __table_args__ = ( + Index('idx_usage_user_action', 'user_id', 'action'), + Index('idx_usage_user_time', 'user_id', 'created_time'), + Index('idx_usage_action_time', 'action', 'created_time'), + {'comment': '使用日志表'} + ) \ No newline at end of file diff --git a/backend/app/admin/model/points.py b/backend/app/admin/model/points.py new file mode 100644 index 0000000..d4ec9f8 --- /dev/null +++ b/backend/app/admin/model/points.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import String, Column, BigInteger, ForeignKey, DateTime, Index, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from backend.common.model import snowflake_id_key, Base + + +class Points(Base): + __tablename__ = 'points' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), unique=True, nullable=False, comment='关联的用户ID') + balance: Mapped[int] = mapped_column(BigInteger, default=0, comment='当前积分余额') + total_earned: Mapped[int] = mapped_column(BigInteger, default=0, comment='累计获得积分') + total_spent: Mapped[int] = mapped_column(BigInteger, default=0, comment='累计消费积分') + expired_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now() + timedelta(days=30), comment="过期时间") + + # 索引优化 + __table_args__ = ( + Index('idx_points_user', 'user_id'), + {'comment': '用户积分表'} + ) + + +class PointsLog(Base): + __tablename__ = 'points_log' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=False, comment='用户ID') + action: Mapped[str] = mapped_column(String(32), comment='动作:earn/spend') + amount: Mapped[int] = mapped_column(BigInteger, comment='变动数量') + balance_after: Mapped[int] = mapped_column(BigInteger, comment='变动后余额') + related_id: Mapped[Optional[int]] = mapped_column(BigInteger, default=None, comment='关联ID') + details: Mapped[Optional[dict]] = mapped_column(JSONB, default=None, comment='附加信息') + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment='创建时间') + + # 索引优化 + __table_args__ = ( + Index('idx_points_log_user_action', 'user_id', 'action'), + Index('idx_points_log_user_time', 'user_id', 'created_at'), + {'comment': '积分变动日志表'} + ) \ No newline at end of file diff --git a/backend/app/admin/model/wx_user.py b/backend/app/admin/model/wx_user.py new file mode 100755 index 0000000..8017bd3 --- /dev/null +++ b/backend/app/admin/model/wx_user.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Optional + +from sqlalchemy import String, Column, BigInteger, SmallInteger, Boolean, DateTime, Index, func, JSON, Text, Numeric +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from backend.common.model import snowflake_id_key, Base + + +class WxUser(Base): + __tablename__ = 'wx_user' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + openid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment='微信OpenID') + session_key: Mapped[str] = mapped_column(String(128), nullable=False, comment='会话密钥') + unionid: Mapped[Optional[str]] = mapped_column(String(64), default=None, index=True, comment='微信UnionID') + mobile: Mapped[Optional[str]] = mapped_column(String(15), default=None, index=True, comment='加密手机号') + profile: Mapped[Optional[dict]] = mapped_column(JSONB(astext_type=Text()), default=None, comment='用户资料JSON') + + +# class WxPayment(Base): +# __tablename__ = 'wx_payments' +# +# id: Mapped[snowflake_id_key] = mapped_column(init=False, primary_key=True) +# user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False) +# prepay_id: Mapped[str] = mapped_column(String(64), nullable=False, comment='预支付ID') +# transaction_id: Mapped[Optional[str]] = mapped_column(String(32), comment='微信支付单号') +# amount: Mapped[float] = mapped_column(Numeric, nullable=False, comment='分单位金额') +# status: Mapped[str] = mapped_column(String(16), default='pending', comment='支付状态') +# +# __table_args__ = ( +# Index('idx_payment_user', 'user_id', 'status'), +# {'comment': '支付记录表'} +# ) diff --git a/backend/app/admin/schema/__init__.py b/backend/app/admin/schema/__init__.py new file mode 100755 index 0000000..32be48c --- /dev/null +++ b/backend/app/admin/schema/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from backend.app.admin.schema.file import FileSchemaBase, AddFileParam, UpdateFileParam, FileInfoSchema, FileUploadResponse +from backend.app.admin.schema.audit_log import DailySummarySchema, DailySummaryPageSchema +from backend.app.admin.schema.points import PointsSchema, PointsLogSchema, AddPointsRequest, DeductPointsRequest \ No newline at end of file diff --git a/backend/app/admin/schema/audit_log.py b/backend/app/admin/schema/audit_log.py new file mode 100755 index 0000000..0ee18a4 --- /dev/null +++ b/backend/app/admin/schema/audit_log.py @@ -0,0 +1,69 @@ +from datetime import datetime +from typing import Optional, List + +from pydantic import ConfigDict, Field + +from backend.common.schema import SchemaBase + + +class AuditLogSchemaBase(SchemaBase): + """ Audit Log Schema """ + api_type: str = Field(description="API类型: recognition/embedding/assessment") + model_name: str = Field(description="模型名称") + request_data: Optional[dict] = Field(None, description="请求数据") + response_data: Optional[dict] = Field(None, description="响应数据") + token_usage: Optional[dict] = Field(0, description="消耗的token数量") + cost: Optional[float] = Field(0.0, description="API调用成本") + duration: float = Field(description="调用耗时(秒)") + status_code: int = Field(description="HTTP状态码") + error_message: Optional[str] = Field("", description="错误信息") + called_at: Optional[datetime] = Field(None, description="调用时间") + image_id: int = Field(description="关联的图片ID") + user_id: int = Field(description="调用用户ID") + api_version: str = Field(description="API版本") + dict_level: Optional[str] = Field(None, description="词典等级") + + +class CreateAuditLogParam(AuditLogSchemaBase): + """创建操作日志参数""" + + +class AuditLogHistorySchema(SchemaBase): + """ Audit Log History Schema for user history records """ + image_id: Optional[str] = Field(None, description="图ID") + file_id: Optional[str] = Field(None, description="原图ID") + thumbnail_id: Optional[str] = Field(None, description="缩略图ID") + created_time: Optional[str] = Field(description="图片创建时间") + dict_level: Optional[str] = Field(None, description="词典等级") + + +class AuditLogStatisticsSchema(SchemaBase): + """ Audit Log Statistics Schema """ + total_count: int = Field(description="历史总量") + today_count: int = Field(description="当天总量") + image_count: int = Field(description="图片总量") + + +class CreateDailySummaryParam(SchemaBase): + """创建每日总结参数""" + user_id: int = Field(description="调用用户ID") + image_ids: Optional[List[str]] = Field(None, description="图ID") + thumbnail_ids: Optional[List[str]] = Field(None, description="缩略图ID") + summary_time: Optional[datetime] = Field(None, description="调用时间") + + +class DailySummarySchema(SchemaBase): + """ Daily Summary Schema """ + # id: int = Field(description="记录ID") + # user_id: int = Field(description="用户ID") + image_ids: List[str] = Field(description="图片ID列表") + thumbnail_ids: List[str] = Field(description="图片缩略图列表") + summary_time: str = Field(description="创建时间") + + +class DailySummaryPageSchema(SchemaBase): + """ Daily Summary Page Schema """ + items: List[DailySummarySchema] = Field(description="每日汇总记录列表") + total: int = Field(description="总记录数") + page: int = Field(description="当前页码") + size: int = Field(description="每页记录数") \ No newline at end of file diff --git a/backend/app/admin/schema/captcha.py b/backend/app/admin/schema/captcha.py new file mode 100755 index 0000000..3337a91 --- /dev/null +++ b/backend/app/admin/schema/captcha.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from pydantic import Field + +from backend.common.schema import SchemaBase + + +class GetCaptchaDetail(SchemaBase): + image_type: str = Field(description='图片类型') + image: str = Field(description='图片内容') diff --git a/backend/app/admin/schema/dict.py b/backend/app/admin/schema/dict.py new file mode 100755 index 0000000..a23ccfc --- /dev/null +++ b/backend/app/admin/schema/dict.py @@ -0,0 +1,198 @@ +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any, List, Union + +# --- 辅助模型 --- + +class CrossReference(BaseModel): + # 交叉引用的文本,通常是另一个词条名 + text: Optional[str] = None + sense_id: Optional[str] = None + # 链接到另一个词条的 href + entry_href: Optional[str] = None # 例如: "entry://cooking-apple" + # 如果是图片相关的交叉引用,可能包含图片信息 + # 图片的 ID (来自 a 标签的 showid 属性) + show_id: Optional[str] = None # 例如: "img3241" + # image_filename: 指向 dict_media 表中的 file_name + image_filename: Optional[str] = None # 例如: "img3241_ldoce4188jpg" (由 showid 和 base64 属性组合) + # 图片的标题/描述 (来自 a 标签的 title 属性) + image_title: Optional[str] = None # 例如: "apple from LDOCE 4" + # LDOCE 版本信息 (如果适用) + ldoce_version: Optional[str] = None # 例如: "LDOCEVERSION_5", "LDOCEVERSION_new" + +class FamilyItem(BaseModel): + text: Optional[str] = None + # 链接词用 href 存储,非链接词 href 为 None + href: Optional[str] = None # 例如: "bword://underworld" + +class WordFamily(BaseModel): + pos: Optional[str] = None # 词性,如 "noun", "adjective" + items: List[FamilyItem] = [] # 该词性下的相关词项 + +class Pronunciation(BaseModel): + # IPA 音标 + uk_ipa: Optional[str] = None # 例如: "ˈæpəl" + us_ipa: Optional[str] = None # 例如: "ˈæpəl" 或 "wɜːrld" + # 音频文件路径 (相对或绝对) + uk_audio: Optional[str] = None # 例如: "/media/english/breProns/brelasdeapple.mp3" + us_audio: Optional[str] = None # 例如: "/media/english/ameProns/apple1.mp3" + # title + uk_audio_title: Optional[str] = None # 例如: "Play British pronunciation of plane" + us_audio_title: Optional[str] = None # 例如: "Play American pronunciation of plane" + +class Frequency(BaseModel): + level: Optional[str] = None # 例如: "Core vocabulary: High-frequency" + spoken: Optional[str] = None # 例如: "Top 2000 spoken words" + written: Optional[str] = None # 例如: "Top 3000 written words" + level_tag: Optional[str] = None # 例如: "●●●" + spoken_tag: Optional[str] = None # 例如: "S2" + written_tag: Optional[str] = None # 例如: "W2" + +# --- 核心模型 --- + +class Example(BaseModel): + # 英文例句 (包含可能的高亮或链接,但这里简化为纯文本) + en: Optional[str] = None + # 中文翻译 + cn: Optional[str] = None + # 例句中突出的搭配 (如 "a world of difference") + collocation: Optional[str] = None + # 例句中链接到的其他词条 (如 "wanting", "loving") + related_words_in_example: Optional[List[str]] = Field(default_factory=list) + # 例句音频文件路径 + audio: Optional[str] = None # 例如: "/media/english/exaProns/p008-000499910.mp3" + +class Definition(BaseModel): + # 英文定义 + en: Optional[str] = None + # 中文定义 + cn: Optional[str] = None + # 定义中链接到的其他词条 (来自 defRef 标签) + related_words: Optional[List[str]] = Field(default_factory=list) + +class Sense(BaseModel): + # Sense 的唯一标识符 (来自 HTML id) + id: Optional[str] = None # 例如: "apple__1", "world__3" + # Sense 编号 (如果存在) + number: Optional[str] = None # 例如: "1", "2", "a)" + # Signpost (英文标签,如 "OUR PLANET/EVERYONE ON IT") + signpost_en: Optional[str] = None + # Signpost 中文翻译 + signpost_cn: Optional[str] = None + ref_hwd: Optional[str] = None + # 语法信息 (如果该 Sense 特有) + grammar: Optional[str] = None # 可以是字符串或更复杂的结构,视情况而定 + # 定义 (可能有中英对照) + definitions: Optional[List[Definition]] = Field(default_factory=list) + # 例子 (属于该 Sense) + examples: Optional[List[Example]] = Field(default_factory=list) + # 图片信息 (如果该 Sense 有图) + image: Optional[str] = None # 图片文件名或路径, 例如: "apple.jpg" + # Sense 内的交叉引用 (来自 Crossref 标签) + cross_references: Optional[List[CrossReference]] = Field(default_factory=list) + # 可数性 + countability: Optional[List[str]] = Field(default_factory=list) + +class Topic(BaseModel): + # Topic 名称 + name: Optional[str] = None # 例如: "Food, dish", "Astronomy" + # 链接到 Topic 的 href + href: Optional[str] = None # 例如: "entry://Food, dish-topic food" + +class DictEntry(BaseModel): + # 词条名 (标准化形式) + headword: Optional[str] = None # 例如: "apple", "world" + # 同形异义词编号 (如果存在) + homograph_number: Optional[int] = None # 例如: 1, 2 (对应 HOMNUM) + # 词性 (主要词性,或第一个 Sense 的词性) + part_of_speech: Optional[str] = None # 例如: "noun", "verb" + transitive: Optional[List[str]] = Field(default_factory=list) # 例如: "transitive" + # 发音 + pronunciations: Optional[Pronunciation] = None + # 频率信息 + frequency: Optional[Frequency] = None + # 相关话题 (Topics) + topics: Optional[List[Topic]] = Field(default_factory=list) + # 词族信息 + word_family: Optional[List[WordFamily]] = None + # 词条级别的语法信息 (如果适用,不常见) + entry_grammar: Optional[str] = None + # 所有义项 (Sense) + senses: Optional[List[Sense]] = Field(default_factory=list) + +class EtymologyItem(BaseModel): + language: Optional[str] = None + origin: Optional[str] = None + +class Etymology(BaseModel): + intro: Optional[str] = None + headword: Optional[str] = None + hom_num: Optional[str] = None + item: Optional[List[EtymologyItem]] = None + +class WordMetaData(BaseModel): + ref_link: Optional[List[str]] = None + dict_list: List[DictEntry] = Field(default_factory=list) + etymology: Optional[Etymology] = None + # 来源词典信息 (可选) + source_dict: Optional[str] = "Longman Dictionary of Contemporary English 5++" # 可根据需要调整 + +# --- 简化的查询响应模型 --- + +class SimpleCrossReference(BaseModel): + """简化的交叉引用模型""" + text: Optional[str] = None + show_id: Optional[str] = None + sense_id: Optional[str] = None + image_filename: Optional[str] = None + +class SimpleExample(BaseModel): + """简化的例句模型""" + cn: Optional[str] = None + en: Optional[str] = None + audio: Optional[str] = None + +class SimpleDefinition(BaseModel): + """简化的定义模型""" + cn: Optional[str] = None + en: Optional[str] = None + related_words: Optional[List[str]] = Field(default_factory=list) + +class SimpleSense(BaseModel): + """简化的义项模型""" + id: Optional[str] = None + image: Optional[str] = None + number: Optional[str] = None + grammar: Optional[str] = None + ref_hwd: Optional[str] = None + examples: Optional[List[SimpleExample]] = Field(default_factory=list) + definitions: Optional[List[SimpleDefinition]] = Field(default_factory=list) + signpost_cn: Optional[str] = None + signpost_en: Optional[str] = None + cross_references: Optional[List[SimpleCrossReference]] = Field(default_factory=list) + countability: Optional[List[str]] = Field(default_factory=list) + +class SimpleFrequency(BaseModel): + """简化的频率信息模型""" + level_tag: Optional[str] = None + spoken_tag: Optional[str] = None + written_tag: Optional[str] = None + +class SimplePronunciation(BaseModel): + """简化的发音模型""" + uk_ipa: Optional[str] = None + us_ipa: Optional[str] = None + uk_audio: Optional[str] = None + us_audio: Optional[str] = None + +class SimpleDictEntry(BaseModel): + """字典单词查询响应模型(缩减版 DictEntry)""" + part_of_speech: Optional[str] = None # 例如: "noun", "verb" + transitive: Optional[List[str]] = Field(default_factory=list) + senses: Optional[List[SimpleSense]] = Field(default_factory=list) + frequency: Optional[SimpleFrequency] = None + pronunciations: Optional[SimplePronunciation] = None + +class DictWordResponse(BaseModel): + dict_list: List[SimpleDictEntry] = Field(default_factory=list) + etymology: Optional[Etymology] = None + diff --git a/backend/app/admin/schema/feedback.py b/backend/app/admin/schema/feedback.py new file mode 100755 index 0000000..0debddf --- /dev/null +++ b/backend/app/admin/schema/feedback.py @@ -0,0 +1,57 @@ +from datetime import datetime +from enum import Enum +from typing import Optional, List + +from pydantic import BaseModel + +from backend.common.schema import SchemaBase + + +class FeedbackStatus(str, Enum): + PENDING = "pending" + PROCESSING = "processing" + RESOLVED = "resolved" + + +class FeedbackCategory(str, Enum): + BUG = "bug" + FEATURE = "feature" + COMPLIMENT = "compliment" + OTHER = "other" + + +class FeedbackSchemaBase(SchemaBase): + content: str + contact_info: Optional[str] = None + category: Optional[FeedbackCategory] = None + metadata_info: Optional[dict] = None + + +class CreateFeedbackParam(FeedbackSchemaBase): + """创建反馈参数""" + pass + + +class UpdateFeedbackParam(FeedbackSchemaBase): + """更新反馈参数""" + status: Optional[FeedbackStatus] = None + + +class FeedbackInfoSchema(FeedbackSchemaBase): + """反馈信息Schema""" + id: int + user_id: int + status: FeedbackStatus + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class FeedbackUserInfoSchema(FeedbackInfoSchema): + """包含用户信息的反馈Schema""" + user: Optional[dict] = None # 简化的用户信息 + + class Config: + from_attributes = True \ No newline at end of file diff --git a/backend/app/admin/schema/file.py b/backend/app/admin/schema/file.py new file mode 100755 index 0000000..4cf7bf1 --- /dev/null +++ b/backend/app/admin/schema/file.py @@ -0,0 +1,52 @@ +from pydantic import BaseModel +from typing import Optional, Dict, Any +from datetime import datetime + +from sqlalchemy.sql.sqltypes import BigInteger + +from backend.common.schema import SchemaBase + + +class FileMetadata(BaseModel): + """文件元数据结构""" + file_name: Optional[str] = None + content_type: Optional[str] = None + file_size: int # 文件大小(字节) + user_agent: Optional[str] = None # 上传客户端信息 + extra: Optional[Dict[str, Any]] = None # 其他自定义元数据 + + +class FileSchemaBase(SchemaBase): + file_hash: str + file_name: str + content_type: Optional[str] = None + file_size: int + storage_type: str = "database" + storage_path: Optional[str] = None + metadata_info: Optional[FileMetadata] = None + + +class AddFileParam(FileSchemaBase): + """添加文件参数""" + pass + + +class UpdateFileParam(FileSchemaBase): + """更新文件参数""" + pass + + +class FileInfoSchema(FileSchemaBase): + """文件信息Schema""" + id: int + created_at: datetime + updated_at: datetime + + +class FileUploadResponse(SchemaBase): + """文件上传响应""" + id: str + file_hash: str + file_name: str + content_type: Optional[str] = None + file_size: int \ No newline at end of file diff --git a/backend/app/admin/schema/points.py b/backend/app/admin/schema/points.py new file mode 100644 index 0000000..2447634 --- /dev/null +++ b/backend/app/admin/schema/points.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class PointsSchema(BaseModel): + """积分账户信息""" + id: int + user_id: int + balance: int = Field(default=0, description="当前积分余额") + total_earned: int = Field(default=0, description="累计获得积分") + total_spent: int = Field(default=0, description="累计消费积分") + expired_time: datetime = Field(default_factory=datetime.now, description="过期时间") + + +class PointsLogSchema(BaseModel): + """积分变动日志""" + id: int + user_id: int + action: str = Field(description="动作:earn/spend") + amount: int = Field(description="变动数量") + balance_after: int = Field(description="变动后余额") + related_id: Optional[int] = Field(default=None, description="关联ID") + details: Optional[dict] = Field(default=None, description="附加信息") + created_at: datetime = Field(default_factory=datetime.now, description="创建时间") + + +class AddPointsRequest(BaseModel): + """增加积分请求""" + user_id: int = Field(description="用户ID") + amount: int = Field(gt=0, description="增加的积分数量") + extend_expiration: bool = Field(default=False, description="是否自动延期过期时间") + related_id: Optional[int] = Field(default=None, description="关联ID") + details: Optional[dict] = Field(default=None, description="附加信息") + + +class DeductPointsRequest(BaseModel): + """扣减积分请求""" + user_id: int = Field(description="用户ID") + amount: int = Field(gt=0, description="扣减的积分数量") + related_id: Optional[int] = Field(default=None, description="关联ID") + details: Optional[dict] = Field(default=None, description="附加信息") \ No newline at end of file diff --git a/backend/app/admin/schema/pydantic_type.py b/backend/app/admin/schema/pydantic_type.py new file mode 100755 index 0000000..76ce691 --- /dev/null +++ b/backend/app/admin/schema/pydantic_type.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, BigInteger, String, Text +from sqlalchemy.dialects.postgresql import JSONB +from pgvector.sqlalchemy import Vector +from sqlalchemy.types import TypeDecorator + +from backend.utils.json_encoder import jsonable_encoder + + +class PydanticType(TypeDecorator): + """处理 Pydantic 模型的 SQLAlchemy 自定义类型""" + impl = JSONB + + def __init__(self, pydantic_type=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.pydantic_type = pydantic_type + + def process_bind_param(self, value, dialect): + if value is None: + return None + return jsonable_encoder(value) + + def process_result_value(self, value, dialect): + if value is None or self.pydantic_type is None: + return value + return self.pydantic_type(**value) \ No newline at end of file diff --git a/backend/app/admin/schema/qwen.py b/backend/app/admin/schema/qwen.py new file mode 100755 index 0000000..846480b --- /dev/null +++ b/backend/app/admin/schema/qwen.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from pydantic import Field + +from backend.common.schema import SchemaBase + + +class QwenParamBase(SchemaBase): + """ Qwen Base Params """ + user_id: int = Field(description="user id") + image_id: int = Field(description="image id") + data: str = Field(description='Base64') + file_name: str = Field(description='文件名') + format: str = Field(description='图片后缀') + dict_level: str = Field(description='dict level') + + +class QwenEmbedImageParams(QwenParamBase): + """ Embedding Image Params """ + + +class QwenRecognizeImageParams(QwenParamBase): + """ Recognize image Params """ + type: str = Field(description='识别类型') + exclude_words: list[str] = Field(description='exclude words') diff --git a/backend/app/admin/schema/token.py b/backend/app/admin/schema/token.py new file mode 100755 index 0000000..f29e122 --- /dev/null +++ b/backend/app/admin/schema/token.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime +from typing import Optional + +from pydantic import Field + +from backend.app.admin.schema.user import GetUserInfoDetail +from backend.common.enums import StatusType +from backend.common.schema import SchemaBase + + +class GetSwaggerToken(SchemaBase): + """Swagger 认证令牌""" + + access_token: str = Field(description='访问令牌') + token_type: str = Field('Bearer', description='令牌类型') + user: GetUserInfoDetail = Field(description='用户信息') + + +class AccessTokenBase(SchemaBase): + """访问令牌基础模型""" + + access_token: str = Field(description='访问令牌') + access_token_expire_time: datetime = Field(description='令牌过期时间') + session_uuid: str = Field(description='会话 UUID') + + +class GetNewToken(AccessTokenBase): + """获取新令牌""" + + +class GetLoginToken(AccessTokenBase): + """获取登录令牌""" + + user: GetUserInfoDetail = Field(description='用户信息') + + +class GetWxLoginToken(AccessTokenBase): + """微信登录令牌""" + dict_level: Optional[str] = Field(None, description="词典等级") + + +class GetTokenDetail(SchemaBase): + """令牌详情""" + + id: int = Field(description='用户 ID') + session_uuid: str = Field(description='会话 UUID') + username: str = Field(description='用户名') + nickname: str = Field(description='昵称') + ip: str = Field(description='IP 地址') + os: str = Field(description='操作系统') + browser: str = Field(description='浏览器') + device: str = Field(description='设备') + status: StatusType = Field(description='状态') + last_login_time: str = Field(description='最后登录时间') + expire_time: datetime = Field(description='过期时间') \ No newline at end of file diff --git a/backend/app/admin/schema/usage.py b/backend/app/admin/schema/usage.py new file mode 100755 index 0000000..3dbb9a3 --- /dev/null +++ b/backend/app/admin/schema/usage.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, Field +from typing import Optional + +class PurchaseRequest(BaseModel): + amount_cents: int = Field(..., ge=100, le=10000000, description="充值金额(分),1元=100分") + +class SubscriptionRequest(BaseModel): + plan: str = Field(..., pattern=r'^(monthly|quarterly|half_yearly|yearly)$', description="订阅计划") + +class RefundRequest(BaseModel): + order_id: int = Field(..., gt=0, description="订单ID") + reason: Optional[str] = Field(None, max_length=200, description="退款原因") diff --git a/backend/app/admin/schema/user.py b/backend/app/admin/schema/user.py new file mode 100755 index 0000000..d6015e7 --- /dev/null +++ b/backend/app/admin/schema/user.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime + +from pydantic import Field, EmailStr, ConfigDict, HttpUrl + +from backend.common.schema import SchemaBase, CustomPhoneNumber + +class AuthSchemaBase(SchemaBase): + username: str = Field(description='用户名') + password: str = Field(description='密码') + + +class AuthLoginParam(AuthSchemaBase): + captcha: str = Field(description='验证码') + + +class RegisterUserParam(AuthSchemaBase): + email: EmailStr = Field(examples=['user@example.com'], description='邮箱') + + +class UpdateUserParam(SchemaBase): + username: str = Field(description='用户名') + email: EmailStr = Field(examples=['user@example.com'], description='邮箱') + phone: CustomPhoneNumber | None = Field(None, description='手机号') + + +class AvatarParam(SchemaBase): + url: HttpUrl = Field(..., description='头像 http 地址') + + +class GetUserInfoDetail(UpdateUserParam): + model_config = ConfigDict(from_attributes=True) + + id: int = Field(description='用户 ID') + uuid: str = Field(description='用户 UUID') + avatar: str | None = Field(None, description='头像') + status: int = Field(description='状态') + is_superuser: bool = Field(description='是否超级管理员') + join_time: datetime = Field(description='加入时间') + last_login_time: datetime | None = Field(None, description='最后登录时间') + + +class ResetPassword(SchemaBase): + username: str = Field(description='用户名') + old_password: str = Field(description='旧密码') + new_password: str = Field(description='新密码') + confirm_password: str = Field(description='确认密码') diff --git a/backend/app/admin/schema/wx.py b/backend/app/admin/schema/wx.py new file mode 100755 index 0000000..bbff6a2 --- /dev/null +++ b/backend/app/admin/schema/wx.py @@ -0,0 +1,70 @@ +# schemas.py +from enum import Enum + +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional + +from backend.common.schema import SchemaBase + + +class WxUserBase(SchemaBase): + id: int = Field(description='User ID') + openid: str = Field(description="微信 user openid") + + +class GetWxUserInfoDetail(WxUserBase): + """用户信息详情""" + + +class GetWxUserInfoWithRelationDetail(GetWxUserInfoDetail): + """用户信息关联详情""" + + +class WxLoginRequest(BaseModel): + code: str = Field(..., description="微信登录code") + appid: Optional[str] = Field(None, description="微信 appid") + encrypted_data: Optional[str] = Field(None, description="加密的用户数据") + iv: Optional[str] = Field(None, description="加密算法的初始向量") + + +class TokenResponse(BaseModel): + access_token: str = Field(..., description="访问令牌") + refresh_token: str = Field(..., description="刷新令牌") + token_type: str = Field("bearer", description="令牌类型") + expires_in: int = Field(..., description="过期时间(秒)") + + +class UserBase(BaseModel): + id: int = Field(..., description="用户ID") + openid: str = Field(..., description="微信OpenID") + + +class UserInfo(UserBase): + mobile: Optional[str] = Field(None, description="手机号") + profile: Optional[dict] = Field({}, description="用户资料") + created_at: datetime = Field(..., description="创建时间") + + +class UserAuth(BaseModel): + access_token: str + refresh_token: str + expires_at: datetime + + +class DictLevel(str, Enum): + LEVEL1 = "LEVEL1" # "小学" + LEVEL2 = "LEVEL2" # "初高中" + LEVEL3 = "LEVEL3" # "四六级" + + +class UserSettings(BaseModel): + dict_level: Optional[DictLevel] = Field(None, description="词典等级") + + +class UpdateUserSettingsRequest(UserSettings): + pass + + +class GetUserSettingsResponse(UserSettings): + pass \ No newline at end of file diff --git a/backend/app/admin/service/__init__.py b/backend/app/admin/service/__init__.py new file mode 100755 index 0000000..bdbc782 --- /dev/null +++ b/backend/app/admin/service/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from backend.app.admin.service.points_service import PointsService diff --git a/backend/app/admin/service/ad_share_service.py b/backend/app/admin/service/ad_share_service.py new file mode 100755 index 0000000..01e4c40 --- /dev/null +++ b/backend/app/admin/service/ad_share_service.py @@ -0,0 +1,247 @@ +from backend.app.admin.crud.user_account_crud import user_account_dao +from backend.app.admin.crud.usage_log_crud import usage_log_dao +from backend.database.db import async_db_session +from backend.common.exception import errors +from datetime import datetime, timedelta +from backend.common.log import log as logger + +# 导入 Redis 客户端 +from backend.database.redis import redis_client + + +class AdShareService: + # 每日限制配置 + DAILY_AD_LIMIT = 5 # 每日广告观看限制 + DAILY_SHARE_LIMIT = 3 # 每日分享限制 + AD_REWARD_TIMES = 3 # 每次广告奖励次数 + SHARE_REWARD_TIMES = 3 # 每次分享奖励次数 + + @staticmethod + def _get_redis_key(user_id: int, action_type: str, date_str: str = None) -> str: + """ + 生成 Redis key + + Args: + user_id: 用户ID + action_type: 动作类型 (ad/share) + date_str: 日期字符串 (YYYY-MM-DD) + + Returns: + str: Redis key + """ + if date_str is None: + date_str = datetime.now().strftime('%Y-%m-%d') + return f"user:{user_id}:{action_type}:count:{date_str}" + + @staticmethod + async def _check_daily_limit(user_id: int, action_type: str, limit: int) -> bool: + """ + 检查每日限制 + + Args: + user_id: 用户ID + action_type: 动作类型 + limit: 限制次数 + + Returns: + bool: 是否超过限制 + """ + try: + # 确保 Redis 连接 + await redis_client.ping() + + # 获取今天的计数 + key = AdShareService._get_redis_key(user_id, action_type) + current_count = await redis_client.get(key) + + if current_count is None: + current_count = 0 + else: + current_count = int(current_count) + + return current_count >= limit + + except Exception as e: + logger.error(f"检查每日限制失败: {str(e)}") + # Redis 异常时允许继续操作(避免服务中断) + return False + + @staticmethod + async def _increment_daily_count(user_id: int, action_type: str) -> int: + """ + 增加每日计数 + + Args: + user_id: 用户ID + action_type: 动作类型 + + Returns: + int: 当前计数 + """ + try: + # 确保 Redis 连接 + await redis_client.ping() + + key = AdShareService._get_redis_key(user_id, action_type) + + # 增加计数 + current_count = await redis_client.incr(key) + + # 设置过期时间(第二天自动清除) + tomorrow = datetime.now() + timedelta(days=1) + tomorrow_midnight = tomorrow.replace(hour=0, minute=0, second=0, microsecond=0) + expire_seconds = int((tomorrow_midnight - datetime.now()).total_seconds()) + + if expire_seconds > 0: + await redis_client.expire(key, expire_seconds) + + return current_count + + except Exception as e: + logger.error(f"增加每日计数失败: {str(e)}") + raise errors.ServerError(msg="系统繁忙,请稍后重试") + + @staticmethod + async def _get_daily_count(user_id: int, action_type: str) -> int: + """ + 获取今日计数 + + Args: + user_id: 用户ID + action_type: 动作类型 + + Returns: + int: 当前计数 + """ + try: + # 确保 Redis 连接 + await redis_client.ping() + + key = AdShareService._get_redis_key(user_id, action_type) + count = await redis_client.get(key) + + return int(count) if count is not None else 0 + + except Exception as e: + logger.error(f"获取每日计数失败: {str(e)}") + return 0 + + @staticmethod + async def grant_times_by_ad(user_id: int): + """ + 通过观看广告获得次数 + + Args: + user_id: 用户ID + """ + # 检查每日限制 + if await AdShareService._check_daily_limit(user_id, "ad", AdShareService.DAILY_AD_LIMIT): + raise errors.ForbiddenError(msg=f"今日广告观看次数已达上限({AdShareService.DAILY_AD_LIMIT}次)") + + async with async_db_session.begin() as db: + try: + # 增加计数 + current_count = await AdShareService._increment_daily_count(user_id, "ad") + + # 增加用户余额 + result = await user_account_dao.update_balance_atomic(db, user_id, AdShareService.AD_REWARD_TIMES) + if not result: + # 回滚计数 + key = AdShareService._get_redis_key(user_id, "ad") + await redis_client.decr(key) + raise errors.ServerError(msg="账户更新失败") + + # 记录使用日志 + account = await user_account_dao.get_by_user_id(db, user_id) + await usage_log_dao.add(db, { + "user_id": user_id, + "action": "ad", + "amount": AdShareService.AD_REWARD_TIMES, + "balance_after": account.balance if account else AdShareService.AD_REWARD_TIMES, + "metadata_": { + "daily_count": current_count, + "max_limit": AdShareService.DAILY_AD_LIMIT + } + }) + + except Exception as e: + if not isinstance(e, errors.ForbiddenError): + logger.error(f"广告奖励处理失败: {str(e)}") + raise + + @staticmethod + async def grant_times_by_share(user_id: int): + """ + 通过分享获得次数 + + Args: + user_id: 用户ID + """ + # 检查每日限制 + if await AdShareService._check_daily_limit(user_id, "share", AdShareService.DAILY_SHARE_LIMIT): + raise errors.ForbiddenError(msg=f"今日分享次数已达上限({AdShareService.DAILY_SHARE_LIMIT}次)") + + async with async_db_session.begin() as db: + try: + # 增加计数 + current_count = await AdShareService._increment_daily_count(user_id, "share") + + # 增加用户余额 + result = await user_account_dao.update_balance_atomic(db, user_id, AdShareService.SHARE_REWARD_TIMES) + if not result: + # 回滚计数 + key = AdShareService._get_redis_key(user_id, "share") + await redis_client.decr(key) + raise errors.ServerError(msg="账户更新失败") + + # 记录使用日志 + account = await user_account_dao.get_by_user_id(db, user_id) + await usage_log_dao.add(db, { + "user_id": user_id, + "action": "share", + "amount": AdShareService.SHARE_REWARD_TIMES, + "balance_after": account.balance if account else AdShareService.SHARE_REWARD_TIMES, + "metadata_": { + "daily_count": current_count, + "max_limit": AdShareService.DAILY_SHARE_LIMIT + } + }) + + except Exception as e: + if not isinstance(e, errors.ForbiddenError): + logger.error(f"分享奖励处理失败: {str(e)}") + raise + + @staticmethod + async def get_daily_stats(user_id: int) -> dict: + """ + 获取用户今日统计信息 + + Args: + user_id: 用户ID + + Returns: + dict: 统计信息 + """ + try: + ad_count = await AdShareService._get_daily_count(user_id, "ad") + share_count = await AdShareService._get_daily_count(user_id, "share") + + return { + "ad_count": ad_count, + "ad_limit": AdShareService.DAILY_AD_LIMIT, + "share_count": share_count, + "share_limit": AdShareService.DAILY_SHARE_LIMIT, + "can_watch_ad": ad_count < AdShareService.DAILY_AD_LIMIT, + "can_share": share_count < AdShareService.DAILY_SHARE_LIMIT + } + except Exception as e: + logger.error(f"获取每日统计失败: {str(e)}") + return { + "ad_count": 0, + "ad_limit": AdShareService.DAILY_AD_LIMIT, + "share_count": 0, + "share_limit": AdShareService.DAILY_SHARE_LIMIT, + "can_watch_ad": True, + "can_share": True + } diff --git a/backend/app/admin/service/audit_log_service.py b/backend/app/admin/service/audit_log_service.py new file mode 100755 index 0000000..f72117b --- /dev/null +++ b/backend/app/admin/service/audit_log_service.py @@ -0,0 +1,146 @@ +from backend.app.admin.crud.audit_log_crud import audit_log_dao +from backend.app.admin.crud.daily_summary_crud import daily_summary_dao +from backend.app.admin.schema.audit_log import CreateAuditLogParam, AuditLogHistorySchema, DailySummarySchema +from backend.database.db import async_db_session +from typing import List, Tuple +from datetime import datetime, date +from collections import defaultdict + + +class AuditLogService: + """ Audit Log Service """ + + @staticmethod + async def create(*, obj: CreateAuditLogParam) -> None: + """ + 创建操作日志 + + :param obj: 操作日志创建参数 + :return: + """ + async with async_db_session.begin() as db: + await audit_log_dao.create(db, obj) + + @staticmethod + async def get_user_recognition_history( + *, + user_id: int, + page: int = 1, + size: int = 20 + ) -> Tuple[List[AuditLogHistorySchema], int]: + """ + 通过用户ID查询历史记录 + + :param user_id: 用户ID + :param page: 页码 + :param size: 每页数量 + :return: 历史记录列表和总数 + """ + async with async_db_session() as db: + items, total = await audit_log_dao.get_user_recognition_history(db, user_id, page, size) + + # 转换为 schema 对象 + history_items = [ + AuditLogHistorySchema( + image_id=str(item.id), + file_id=str(item.file_id), + thumbnail_id=str(item.thumbnail_id), + created_time=item.created_time.strftime("%Y-%m-%d %H:%M:%S") if item.created_time else None, + dict_level=item.dict_level + ) + for item in items + ] + + return history_items, total + + @staticmethod + async def get_user_recognition_statistics(*, user_id: int) -> Tuple[int, int, int]: + """ + 统计用户 recognition 类型的使用记录 + 返回历史总量和当天总量 + + :param user_id: 用户ID + :return: (历史总量, 当天总量) + """ + async with async_db_session() as db: + total_count, today_count, image_count = await audit_log_dao.get_user_recognition_statistics(db, user_id) + return total_count, today_count, image_count + + @staticmethod + async def get_user_daily_summaries( + *, + user_id: int, + page: int = 1, + size: int = 20 + ) -> Tuple[List[DailySummarySchema], int]: + """ + 通过用户ID查询每日识别汇总记录,按创建时间降序排列 + + :param user_id: 用户ID + :param page: 页码 + :param size: 每页数量 + :return: 每日汇总记录列表和总数 + """ + async with async_db_session() as db: + items, total = await daily_summary_dao.get_user_daily_summaries(db, user_id, page, size) + + # 转换为 schema 对象 + summary_items = [ + DailySummarySchema( + image_ids=item.image_ids, + thumbnail_ids=item.thumbnail_ids, + summary_time=str(item.summary_time.date()) + ) + for item in items + ] + + return summary_items, total + + @staticmethod + async def get_user_today_summaries( + *, + user_id: int, + page: int = 1, + size: int = 20 + ) -> Tuple[List[DailySummarySchema], int]: + """ + 获取用户当天的识别记录摘要 + 查询当天时间内,AuditLog.api_type == 'recognition' 的所有记录, + 获取相关的图片和缩略图,构成返回结构中的数据 + + :param user_id: 用户ID + :param page: 页码 + :param size: 每页数量 + :return: 当天识别记录摘要列表和总数 + """ + async with async_db_session() as db: + # 获取当天的识别记录 + items, total = await audit_log_dao.get_user_today_recognition_history(db, user_id, page, size) + + # 如果没有记录,返回空列表 + if not items: + return [], total + + # 提取图片ID和缩略图ID + image_ids = [] + thumbnail_ids = [] + + for item in items: + if item.id: + image_ids.append(str(item.id)) + if item.thumbnail_id: + thumbnail_ids.append(str(item.thumbnail_id)) + + # 构建返回数据 + # 由于是当天的数据,所有记录都属于同一天 + today = date.today() + summary_item = DailySummarySchema( + image_ids=image_ids, + thumbnail_ids=thumbnail_ids, + summary_time=str(today) + ) + + return [summary_item], total + + +audit_log_service: AuditLogService = AuditLogService() \ No newline at end of file diff --git a/backend/app/admin/service/audit_service.py b/backend/app/admin/service/audit_service.py new file mode 100755 index 0000000..2a567ff --- /dev/null +++ b/backend/app/admin/service/audit_service.py @@ -0,0 +1,171 @@ +# audit_service.py +from sqlalchemy import func, extract, case, Integer +from sqlalchemy.sql import label + +from datetime import datetime, timedelta +from typing import List, Dict, Any + +from backend.app.admin.model.audit_log import AuditLog +from backend.core.database import SessionLocal +from backend.common.log import log as logger + + +class AuditService: + @staticmethod + def get_audit_logs(page: int = 1, page_size: int = 20, + filters: Dict[str, Any] = None) -> Dict[str, Any]: + """获取审计日志(分页)""" + with SessionLocal() as db: + query = db.query(AuditLog) + + # 应用过滤条件 + if filters: + if filters.get("api_type"): + query = query.filter(AuditLog.api_type == filters["api_type"]) + if filters.get("model_name"): + query = query.filter(AuditLog.model_name == filters["model_name"]) + if filters.get("status_code"): + query = query.filter(AuditLog.status_code == filters["status_code"]) + if filters.get("start_date"): + query = query.filter(AuditLog.called_at >= filters["start_date"]) + if filters.get("end_date"): + query = query.filter(AuditLog.called_at <= filters["end_date"]) + if filters.get("user_id"): + query = query.filter(AuditLog.user_id == filters["user_id"]) + + # 分页处理 + total = query.count() + logs = query.order_by(AuditLog.called_at.desc()) \ + .offset((page - 1) * page_size) \ + .limit(page_size) \ + .all() + + return { + "total": total, + "page": page, + "page_size": page_size, + "results": [log.to_dict() for log in logs] + } + + @staticmethod + def get_usage_statistics(time_range: str = "daily") -> Dict[str, Any]: + """获取API使用统计""" + with SessionLocal() as db: + # 根据时间范围确定分组方式 + if time_range == "hourly": + time_group = func.date_trunc('hour', AuditLog.called_at) + elif time_range == "weekly": + time_group = func.date_trunc('week', AuditLog.called_at) + elif time_range == "monthly": + time_group = func.date_trunc('month', AuditLog.called_at) + else: # daily + time_group = func.date_trunc('day', AuditLog.called_at) + + # 基本统计 + stats = db.query( + time_group.label("time_period"), + AuditLog.api_type, + AuditLog.model_name, + func.count().label("request_count"), + func.sum(AuditLog.cost).label("total_cost"), + func.avg(AuditLog.duration).label("avg_duration"), + func.sum( + case( + (AuditLog.status_code >= 200, 1), + else_=0 + ) + ).label("success_count"), + func.sum( + case( + (AuditLog.status_code >= 400, 1), + else_=0 + ) + ).label("error_count") + ).group_by( + "time_period", + AuditLog.api_type, + AuditLog.model_name + ).order_by( + "time_period" + ).all() + + # Token使用统计 + token_stats = db.query( + time_group.label("time_period"), + AuditLog.api_type, + AuditLog.model_name, + func.sum(AuditLog.token_usage['input_tokens'].astext.cast(Integer)).label("input_tokens"), + func.sum(AuditLog.token_usage['output_tokens'].astext.cast(Integer)).label("output_tokens"), + func.sum(AuditLog.token_usage['total_tokens'].astext.cast(Integer)).label("total_tokens") + ).filter( + AuditLog.token_usage != None + ).group_by( + "time_period", + AuditLog.api_type, + AuditLog.model_name + ).order_by( + "time_period" + ).all() + + # 转换结果 + return { + "usage_stats": [ + { + "time_period": s.time_period, + "api_type": s.api_type, + "model_name": s.model_name, + "request_count": s.request_count, + "total_cost": float(s.total_cost) if s.total_cost else 0.0, + "avg_duration": float(s.avg_duration) if s.avg_duration else 0.0, + "success_rate": s.success_count / s.request_count if s.request_count else 0.0, + "error_rate": s.error_count / s.request_count if s.request_count else 0.0 + } for s in stats + ], + "token_stats": [ + { + "time_period": t.time_period, + "api_type": t.api_type, + "model_name": t.model_name, + "input_tokens": t.input_tokens, + "output_tokens": t.output_tokens, + "total_tokens": t.total_tokens + } for t in token_stats + ] + } + + @staticmethod + def get_cost_forecast() -> Dict[str, Any]: + """预测未来成本""" + with SessionLocal() as db: + # 获取最近30天的成本数据 + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=30) + + daily_costs = db.query( + func.date_trunc('day', AuditLog.called_at).label("day"), + func.sum(AuditLog.cost).label("daily_cost") + ).filter( + AuditLog.called_at >= start_date, + AuditLog.called_at <= end_date + ).group_by("day").order_by("day").all() + + # 简单预测模型 (移动平均) + costs = [float(d.daily_cost) if d.daily_cost else 0.0 for d in daily_costs] + avg_cost = sum(costs) / len(costs) if costs else 0.0 + + # 生成预测 (未来7天) + forecast = [] + for i in range(1, 8): + forecast_date = end_date + timedelta(days=i) + forecast.append({ + "date": forecast_date.date(), + "predicted_cost": avg_cost + }) + + return { + "historical": [ + {"date": d.day.date(), "cost": float(d.daily_cost) if d.daily_cost else 0.0} + for d in daily_costs + ], + "forecast": forecast + } \ No newline at end of file diff --git a/backend/app/admin/service/auth_service.py b/backend/app/admin/service/auth_service.py new file mode 100755 index 0000000..8dc7a9b --- /dev/null +++ b/backend/app/admin/service/auth_service.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import Request +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.admin.crud.wx_user_crud import wx_user_dao +from backend.app.admin.model import WxUser +from backend.app.admin.schema.token import GetLoginToken +from backend.app.admin.schema.user import AuthLoginParam +from backend.common.exception import errors +from backend.common.response.response_code import CustomErrorCode +from backend.common.security.jwt import password_verify, create_access_token +from backend.core.conf import settings +from backend.database.db import async_db_session +from backend.database.redis import redis_client +from backend.utils.timezone import timezone + + +class AuthService: + @staticmethod + async def user_verify(db: AsyncSession, username: str, password: str) -> WxUser: + user = await wx_user_dao.get_by_username(db, username) + if not user: + raise errors.NotFoundError(msg='用户名或密码有误') + elif not password_verify(password, user.password): + raise errors.AuthorizationError(msg='用户名或密码有误') + elif not user.status: + raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员') + return user + + async def swagger_login(self, *, form_data: OAuth2PasswordRequestForm) -> tuple[str, WxUser]: + async with async_db_session() as db: + user = await self.user_verify(db, form_data.username, form_data.password) + await wx_user_dao.update_login_time(db, user.username, login_time=timezone.now()) + token = create_access_token(str(user.id)) + return token, user + + async def login(self, *, request: Request, obj: AuthLoginParam) -> GetLoginToken: + async with async_db_session() as db: + user = await self.user_verify(db, obj.username, obj.password) + try: + captcha_uuid = request.app.state.captcha_uuid + redis_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{captcha_uuid}') + if not redis_code: + raise errors.ForbiddenError(msg='验证码失效,请重新获取') + except AttributeError: + raise errors.ForbiddenError(msg='验证码失效,请重新获取') + if redis_code.lower() != obj.captcha.lower(): + raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR) + await wx_user_dao.update_login_time(db, user.username, login_time=timezone.now()) + token = create_access_token(str(user.id)) + data = GetLoginToken(access_token=token, user=user) + return data + + +auth_service: AuthService = AuthService() diff --git a/backend/app/admin/service/coupon_service.py b/backend/app/admin/service/coupon_service.py new file mode 100755 index 0000000..0efecc8 --- /dev/null +++ b/backend/app/admin/service/coupon_service.py @@ -0,0 +1,148 @@ +import random +import string +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.admin.crud.coupon_crud import coupon_dao, coupon_usage_dao +from backend.app.admin.model.coupon import Coupon +from backend.database.db import async_db_session +from backend.common.exception import errors +from datetime import datetime, timedelta + + +class CouponService: + + @staticmethod + def generate_unique_code(length: int = 6) -> str: + """ + 生成唯一的兑换码 + """ + characters = string.ascii_uppercase + string.digits + # 移除容易混淆的字符 + characters = characters.replace('0', '').replace('O', '').replace('I', '').replace('1') + + while True: + code = ''.join(random.choice(characters) for _ in range(length)) + # 确保生成的兑换码不包含敏感词汇或重复模式 + if not CouponService._has_sensitive_pattern(code): + return code + + @staticmethod + def _has_sensitive_pattern(code: str) -> bool: + """ + 检查兑换码是否包含敏感模式 + """ + # 简单的敏感词检查 + sensitive_words = ['ADMIN', 'ROOT', 'USER', 'TEST'] + for word in sensitive_words: + if word in code: + return True + + # 检查是否为重复字符 + if len(set(code)) == 1: + return True + + return False + + @staticmethod + async def create_coupon(duration: int, expires_days: Optional[int] = None) -> Coupon: + """ + 创建单个兑换券 + """ + async with async_db_session.begin() as db: + # 生成唯一兑换码 + code = CouponService.generate_unique_code() + + # 确保兑换码唯一性 + while await coupon_dao.get_by_code(db, code): + code = CouponService.generate_unique_code() + + # 设置过期时间 + expires_at = None + if expires_days: + expires_at = datetime.now() + timedelta(days=expires_days) + + coupon_data = { + 'code': code, + 'duration': duration, + 'expires_at': expires_at + } + + coupon = await coupon_dao.create_coupon(db, coupon_data) + return coupon + + @staticmethod + async def batch_create_coupons(count: int, duration: int, expires_days: Optional[int] = None) -> List[Coupon]: + """ + 批量创建兑换券 + """ + async with async_db_session.begin() as db: + coupons_data = [] + + # 生成唯一兑换码列表 + codes = set() + while len(codes) < count: + code = CouponService.generate_unique_code() + if code not in codes: + # 检查数据库中是否已存在该兑换码 + existing_coupon = await coupon_dao.get_by_code(db, code) + if not existing_coupon: + codes.add(code) + + # 设置过期时间 + expires_at = None + if expires_days: + expires_at = datetime.now() + timedelta(days=expires_days) + + # 准备数据 + for code in codes: + coupons_data.append({ + 'code': code, + 'duration': duration, + 'expires_at': expires_at + }) + + coupons = await coupon_dao.create_coupons(db, coupons_data) + return coupons + + @staticmethod + async def redeem_coupon(code: str, user_id: int) -> dict: + """ + 兑换兑换券 + """ + async with async_db_session.begin() as db: + # 获取未使用的兑换券 + coupon = await coupon_dao.get_unused_coupon_by_code(db, code) + if not coupon: + raise errors.RequestError(msg='兑换码无效或已过期') + + # 标记为已使用并创建使用记录 + success = await coupon_dao.mark_as_used(db, coupon.id, user_id, coupon.duration) + if not success: + raise errors.ServerError(msg='兑换失败,请稍后重试') + + return { + 'code': coupon.code, + 'duration': coupon.duration, + 'used_at': datetime.now() + } + + @staticmethod + async def get_user_coupon_history(user_id: int, limit: int = 100) -> List[dict]: + """ + 获取用户兑换历史 + """ + async with async_db_session() as db: + usages = await coupon_usage_dao.get_by_user_id(db, user_id, limit) + + history = [] + for usage in usages: + # 获取兑换券信息 + coupon = await coupon_dao.get(db, usage.coupon_id) + if coupon: + history.append({ + 'code': coupon.code, + 'duration': usage.duration, + 'used_at': usage.used_at + }) + + return history \ No newline at end of file diff --git a/backend/app/admin/service/dict_service.py b/backend/app/admin/service/dict_service.py new file mode 100755 index 0000000..9f9acfe --- /dev/null +++ b/backend/app/admin/service/dict_service.py @@ -0,0 +1,262 @@ +import json +from typing import Optional + +from backend.app.admin.crud.dict_crud import dict_dao +from backend.app.admin.model.dict import DictionaryEntry +from backend.app.admin.schema.dict import ( + DictWordResponse, SimpleDictEntry, SimpleSense, SimpleDefinition, SimpleExample, + SimpleCrossReference, SimpleFrequency, SimplePronunciation, WordMetaData +) +from backend.common.exception import errors +from backend.database.db import async_db_session +from backend.database.redis import redis_client + + +class DictService: + # Redis缓存键前缀 + WORD_LINK_CACHE_PREFIX = "dict:word_link:" + # 缓存过期时间(24小时) + CACHE_EXPIRE_TIME = 24 * 60 * 60 + + @staticmethod + def _convert_to_simple_response(entry: DictionaryEntry) -> DictWordResponse: + """将完整的字典条目转换为简化的响应格式""" + if not entry.details: + # 如果没有详细信息,返回空的响应 + return DictWordResponse() + + details: WordMetaData = entry.details + + # 转换字典条目列表 + simple_dict_list = [] + if details.dict_list: + for dict_entry in details.dict_list: + # 转换义项信息 + simple_senses = [] + if dict_entry.senses: + for sense in dict_entry.senses: + # 转换定义 + simple_definitions = [] + if sense.definitions: + for definition in sense.definitions: + simple_definitions.append(SimpleDefinition( + cn=definition.cn, + en=definition.en, + related_words=definition.related_words or [] + )) + + # 转换例句 + simple_examples = [] + if sense.examples: + for example in sense.examples: + simple_examples.append(SimpleExample( + cn=example.cn, + en=example.en, + audio=example.audio + )) + + # 转换交叉引用 + simple_cross_refs = [] + if sense.cross_references: + for cross_ref in sense.cross_references: + simple_cross_refs.append(SimpleCrossReference( + text=cross_ref.text, + show_id=cross_ref.show_id, + sense_id=cross_ref.sense_id, + image_filename=cross_ref.image_filename + )) + + # 创建简化的义项 + simple_sense = SimpleSense( + id=sense.id, + image=sense.image, + number=sense.number, + grammar=sense.grammar, + ref_hwd=sense.ref_hwd, + examples=simple_examples, + definitions=simple_definitions, + signpost_cn=sense.signpost_cn, + signpost_en=sense.signpost_en, + cross_references=simple_cross_refs, + countability = sense.countability or [], + ) + simple_senses.append(simple_sense) + + # 转换频率信息 + simple_frequency = None + if dict_entry.frequency: + simple_frequency = SimpleFrequency( + level_tag=dict_entry.frequency.level_tag, + spoken_tag=dict_entry.frequency.spoken_tag, + written_tag=dict_entry.frequency.written_tag + ) + + # 转换发音信息 + simple_pronunciations = None + if dict_entry.pronunciations: + simple_pronunciations = SimplePronunciation( + uk_ipa=dict_entry.pronunciations.uk_ipa, + us_ipa=dict_entry.pronunciations.us_ipa, + uk_audio=dict_entry.pronunciations.uk_audio, + us_audio=dict_entry.pronunciations.us_audio + ) + + # 创建简化的字典条目 + simple_dict_entry = SimpleDictEntry( + part_of_speech=dict_entry.part_of_speech, + transitive=dict_entry.transitive, + senses=simple_senses, + frequency=simple_frequency, + pronunciations=simple_pronunciations + ) + simple_dict_list.append(simple_dict_entry) + + return DictWordResponse( + dict_list=simple_dict_list, + etymology=details.etymology + ) + + @staticmethod + async def _get_linked_word_from_cache(word: str) -> Optional[str]: + """ + 从Redis缓存中获取单词的链接单词 + 返回None表示没有链接单词,返回具体单词表示有链接单词 + """ + cache_key = f"{DictService.WORD_LINK_CACHE_PREFIX}{word.lower()}" + try: + # 尝试从Redis获取缓存 + cached_result = await redis_client.get(cache_key) + if cached_result is not None: + # 如果缓存的是"None"字符串,表示没有链接单词 + if cached_result == "None": + return word + # 否则返回缓存的链接单词 + return cached_result + return None + except Exception as e: + # 如果Redis出错,不影响主流程 + return None + + @staticmethod + async def _set_linked_word_to_cache(word: str, linked_word: Optional[str]) -> None: + """ + 将单词的链接单词存入Redis缓存 + linked_word为None表示没有链接单词 + """ + cache_key = f"{DictService.WORD_LINK_CACHE_PREFIX}{word.lower()}" + try: + # 如果没有链接单词,存储"None"字符串 + value = linked_word if linked_word is not None else "None" + await redis_client.setex(cache_key, DictService.CACHE_EXPIRE_TIME, value) + except Exception as e: + # 如果Redis出错,不影响主流程 + pass + + @staticmethod + async def _get_linked_word_from_db(word: str) -> Optional[str]: + """ + 从数据库中获取单词的链接单词 + """ + try: + async with async_db_session() as db: + entry = await dict_dao.get_by_word(db, word.strip().lower()) + if not entry: + return None + + # 检查 details 字段内的 json + if (entry.details and + not entry.details.dict_list and + entry.details.ref_link): + # dict_list 为空数组,检查 ref_link 的内容 + ref_links = entry.details.ref_link + if ref_links: + # 获取第一个链接 + first_link = ref_links[0] + # 检查是否包含 "LINK=" 前缀 + if isinstance(first_link, str) and first_link.startswith("LINK="): + # 截取 "LINK=" 后的单词 + referenced_word = first_link[5:] # 去掉 "LINK=" 前缀 + if referenced_word: + return referenced_word + return word + except Exception as e: + # 如果出现任何错误,返回None表示未找到链接单词 + return None + + @staticmethod + async def get_linked_word(word: str) -> str: + """ + 获取单词的链接单词(如果有) + 首先检查Redis缓存,如果没有则查询数据库并更新缓存 + """ + if not word or not word.strip(): + return word + + word = word.strip().lower() + + # 首先尝试从缓存获取 + linked_word = await DictService._get_linked_word_from_cache(word) + if linked_word is not None: + return linked_word + + # 缓存未命中,从数据库查询 + linked_word = await DictService._get_linked_word_from_db(word) + + # 更新缓存 + await DictService._set_linked_word_to_cache(word, linked_word) + + # 返回结果,如果没有链接单词则返回原单词 + return linked_word if linked_word is not None else word + + @staticmethod + async def get_word_info(word: str) -> DictWordResponse: + """根据单词获取字典信息""" + if not word or not word.strip(): + raise errors.ForbiddenError(msg="单词不能为空") + + word = word.strip().lower() + + async with async_db_session() as db: + entry = await dict_dao.get_by_word(db, word) + if not entry: + raise errors.NotFoundError(msg=f"未找到单词 '{word}' 的释义") + + # 使用新的缓存机制获取链接单词 + linked_word = await DictService.get_linked_word(word) + if linked_word != word: + # 如果找到了引用的单词,返回其信息 + referenced_entry = await dict_dao.get_by_word(db, linked_word) + if referenced_entry: + return DictService._convert_to_simple_response(referenced_entry) + + return DictService._convert_to_simple_response(entry) + + @staticmethod + async def check_word_exists(word: str) -> bool: + """检查单词是否存在""" + if not word or not word.strip(): + return False + + word = word.strip().lower() + + async with async_db_session() as db: + entry = await dict_dao.get_by_word(db, word) + return entry is not None + + @staticmethod + async def get_audio_data(file_name: str) -> Optional[bytes]: + """根据文件名获取音频数据""" + if not file_name or not file_name.strip(): + return None + + file_name = file_name.strip() + + async with async_db_session() as db: + media = await dict_dao.get_media_by_filename(db, file_name) + if not media or media.file_type != 'audio': + return None + + return media.file_data + + +dict_service = DictService() \ No newline at end of file diff --git a/backend/app/admin/service/feedback_service.py b/backend/app/admin/service/feedback_service.py new file mode 100755 index 0000000..43c0b48 --- /dev/null +++ b/backend/app/admin/service/feedback_service.py @@ -0,0 +1,66 @@ +from typing import Optional, List + +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.admin.crud.feedback_crud import feedback_dao +from backend.app.admin.model.feedback import Feedback +from backend.app.admin.schema.feedback import CreateFeedbackParam, UpdateFeedbackParam, FeedbackInfoSchema +from backend.database.db import async_db_session + + +class FeedbackService: + @staticmethod + async def create_feedback(user_id: int, obj_in: CreateFeedbackParam) -> FeedbackInfoSchema: + """创建反馈""" + async with async_db_session.begin() as db: + feedback = await feedback_dao.create(db, user_id, obj_in) + return FeedbackInfoSchema.model_validate(feedback) + + @staticmethod + async def get_feedback(feedback_id: int) -> Optional[FeedbackInfoSchema]: + """获取反馈详情""" + async with async_db_session() as db: + feedback = await feedback_dao.get(db, feedback_id) + if feedback: + return FeedbackInfoSchema.model_validate(feedback) + return None + + @staticmethod + async def get_feedback_list( + user_id: Optional[int] = None, + status: Optional[str] = None, + category: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None + ) -> List[FeedbackInfoSchema]: + """获取反馈列表""" + async with async_db_session() as db: + feedbacks = await feedback_dao.get_list(db, user_id, status, category, limit, offset) + return [FeedbackInfoSchema.model_validate(feedback) for feedback in feedbacks] + + @staticmethod + async def update_feedback(feedback_id: int, obj_in: UpdateFeedbackParam) -> bool: + """更新反馈""" + async with async_db_session.begin() as db: + count = await feedback_dao.update(db, feedback_id, obj_in) + return count > 0 + + @staticmethod + async def delete_feedback(feedback_id: int) -> bool: + """删除反馈""" + async with async_db_session.begin() as db: + count = await feedback_dao.delete(db, feedback_id) + return count > 0 + + @staticmethod + async def count_feedbacks( + user_id: Optional[int] = None, + status: Optional[str] = None, + category: Optional[str] = None + ) -> int: + """统计反馈数量""" + async with async_db_session() as db: + return await feedback_dao.count(db, user_id, status, category) + + +feedback_service = FeedbackService() \ No newline at end of file diff --git a/backend/app/admin/service/file_service.py b/backend/app/admin/service/file_service.py new file mode 100755 index 0000000..9105f4f --- /dev/null +++ b/backend/app/admin/service/file_service.py @@ -0,0 +1,419 @@ +import io +import imghdr +from datetime import datetime +from typing import Optional, Dict, Any + +from fastapi import UploadFile +from PIL import Image as PILImage, ExifTags + +from backend.app.admin.crud.file_crud import file_dao +from backend.app.admin.model.file import File +from backend.app.admin.schema.file import AddFileParam, FileUploadResponse, UpdateFileParam, FileMetadata +from backend.app.ai.schema.image import ColorMode, ImageMetadata, ImageFormat +from backend.app.admin.service.file_storage import get_storage_provider, calculate_file_hash +from backend.common.exception import errors +from backend.core.conf import settings +from backend.database.db import async_db_session + + +class FileService: + @staticmethod + def is_image_file(content_type: str, file_content: bytes, file_name: str) -> bool: + """判断是否为图片文件""" + # 首先检查文件扩展名是否在允许的图片类型列表中 + if file_name: + file_ext = file_name.split('.')[-1].lower() + if file_ext in settings.UPLOAD_IMAGE_EXT_INCLUDE: + return True + + # 然后检查content_type + if content_type and content_type.startswith('image/'): + return True + + # 最后通过文件内容检测 + image_format = imghdr.what(None, h=file_content) + return image_format is not None + + @staticmethod + def validate_image_file(file_name: str) -> None: + """验证图片文件类型是否被允许""" + if not file_name: + return + + file_ext = file_name.split('.')[-1].lower() + if file_ext and file_ext not in settings.UPLOAD_IMAGE_EXT_INCLUDE: + raise errors.ForbiddenError(msg=f'[{file_ext}] 此图片格式暂不支持') + + @staticmethod + async def upload_file( + file: UploadFile, + metadata: Optional[dict] = None + ) -> FileUploadResponse: + """上传文件""" + # 读取文件内容 + content = await file.read() + await file.seek(0) # 重置文件指针 + + storage_type = settings.DEFAULT_STORAGE_TYPE + # 计算文件哈希 + file_hash = calculate_file_hash(content) + + # 检查文件是否已存在 + async with async_db_session() as db: + existing_file = await file_dao.get_by_hash(db, file_hash) + if existing_file: + return FileUploadResponse( + id=str(existing_file.id), + file_hash=existing_file.file_hash, + file_name=existing_file.file_name, + content_type=existing_file.content_type, + file_size=existing_file.file_size + ) + + # 获取存储提供者 + storage_provider = get_storage_provider(storage_type) + + # 保存文件到存储 + storage_path = None + file_data = None + + if storage_type == "database": + # 数据库存储,将文件数据保存到数据库 + file_data = content + else: + # 其他存储方式,保存文件并记录路径 + storage_path = await storage_provider.save( + file_id=0, # 临时ID,后续会更新 + content=content, + file_name=file.filename or "unnamed" + ) + + # 创建文件记录 + async with async_db_session.begin() as db: + # 创建文件元数据 + file_metadata_dict = { + "file_name": file.filename, + "content_type": file.content_type, + "file_size": len(content), + "created_at": datetime.now(), + "updated_at": datetime.now(), + "extra": metadata + } + + # 验证图片文件类型 + if file.filename: + FileService.validate_image_file(file.filename) + + # 如果是图片文件,提取图片元数据 + if FileService.is_image_file(file.content_type or "", content, file.filename or ""): + try: + additional_info = { + "file_name": file.filename, + "content_type": file.content_type, + "file_size": len(content), + } + image_metadata = file_service.extract_image_metadata(content, additional_info) + file_metadata_dict["image_info"] = image_metadata.dict() + except Exception as e: + # 如果提取图片元数据失败,记录错误但不中断上传 + file_metadata_dict["extra"] = { + **(metadata or {}), + "image_metadata_error": str(e) + } + + file_metadata = FileMetadata(**file_metadata_dict) + + # 创建文件参数 + file_params = AddFileParam( + file_hash=file_hash, + file_name=file.filename or "unnamed", + content_type=file.content_type, + file_size=len(content), + storage_type=storage_type, + storage_path=storage_path, + metadata_info=file_metadata + ) + + # 保存到数据库 + db_file = await file_dao.create(db, file_params) + + # 如果是本地存储或其他存储,需要更新文件ID + if storage_type != "database": + # 重新保存文件,使用真实的文件ID + storage_path = await storage_provider.save( + file_id=db_file.id, + content=content, + file_name=file.filename or "unnamed" + ) + + # 更新数据库中的存储路径 + update_params = UpdateFileParam( + storage_path=storage_path + ) + await file_dao.update(db, db_file.id, update_params) + db_file.storage_path = storage_path + + # 如果是数据库存储,更新文件数据 + if storage_type == "database": + db_file.file_data = content + await db.flush() # 确保将file_data保存到数据库 + + return FileUploadResponse( + id=str(db_file.id), + file_hash=db_file.file_hash, + file_name=db_file.file_name, + content_type=db_file.content_type, + file_size=db_file.file_size + ) + + @staticmethod + async def upload_file_with_content_type( + file, + content_type: str, + metadata: Optional[dict] = None + ) -> FileUploadResponse: + """上传文件并指定content_type""" + # 读取文件内容 + content = await file.read() + await file.seek(0) # 重置文件指针 + + storage_type = settings.DEFAULT_STORAGE_TYPE + # 计算文件哈希 + file_hash = calculate_file_hash(content) + + # 检查文件是否已存在 + async with async_db_session() as db: + existing_file = await file_dao.get_by_hash(db, file_hash) + if existing_file: + return FileUploadResponse( + id=str(existing_file.id), + file_hash=existing_file.file_hash, + file_name=existing_file.file_name, + content_type=existing_file.content_type, + file_size=existing_file.file_size + ) + + # 获取存储提供者 + storage_provider = get_storage_provider(storage_type) + + # 保存文件到存储 + storage_path = None + file_data = None + + if storage_type == "database": + # 数据库存储,将文件数据保存到数据库 + file_data = content + else: + # 其他存储方式,保存文件并记录路径 + storage_path = await storage_provider.save( + file_id=0, # 临时ID,后续会更新 + content=content, + file_name=getattr(file, 'filename', 'unnamed') or "unnamed" + ) + + # 创建文件记录 + async with async_db_session.begin() as db: + # 创建文件元数据 + file_metadata_dict = { + "file_name": getattr(file, 'filename', 'unnamed'), + "content_type": content_type, + "file_size": len(content), + "created_at": datetime.now(), + "updated_at": datetime.now(), + "extra": metadata + } + + # 验证图片文件类型 + file_name = getattr(file, 'filename', 'unnamed') + if file_name: + FileService.validate_image_file(file_name) + + # 如果是图片文件,提取图片元数据 + if FileService.is_image_file(content_type or "", content, file_name or ""): + try: + additional_info = { + "file_name": file_name, + "content_type": content_type, + "file_size": len(content), + } + image_metadata = file_service.extract_image_metadata(content, additional_info) + file_metadata_dict["image_info"] = image_metadata.dict() + except Exception as e: + # 如果提取图片元数据失败,记录错误但不中断上传 + file_metadata_dict["extra"] = { + **(metadata or {}), + "image_metadata_error": str(e) + } + + file_metadata = FileMetadata(**file_metadata_dict) + + # 创建文件参数 + file_params = AddFileParam( + file_hash=file_hash, + file_name=file_name or "unnamed", + content_type=content_type, + file_size=len(content), + storage_type=storage_type, + storage_path=storage_path, + metadata_info=file_metadata + ) + + # 保存到数据库 + db_file = await file_dao.create(db, file_params) + + # 如果是本地存储或其他存储,需要更新文件ID + if storage_type != "database": + # 重新保存文件,使用真实的文件ID + storage_path = await storage_provider.save( + file_id=db_file.id, + content=content, + file_name=file_name or "unnamed" + ) + + # 更新数据库中的存储路径 + update_params = UpdateFileParam( + storage_path=storage_path + ) + await file_dao.update(db, db_file.id, update_params) + db_file.storage_path = storage_path + + # 如果是数据库存储,更新文件数据 + if storage_type == "database": + db_file.file_data = content + await db.flush() # 确保将file_data保存到数据库 + + return FileUploadResponse( + id=str(db_file.id), + file_hash=db_file.file_hash, + file_name=db_file.file_name, + content_type=db_file.content_type, + file_size=db_file.file_size + ) + + @staticmethod + async def get_file(file_id: int) -> Optional[File]: + """获取文件信息""" + async with async_db_session() as db: + return await file_dao.get(db, file_id) + + @staticmethod + async def download_file(file_id: int) -> tuple[bytes, str, str]: + """下载文件""" + async with async_db_session() as db: + db_file = await file_dao.get(db, file_id) + if not db_file: + raise errors.NotFoundError(msg="文件不存在") + + content = b"" + storage_provider = get_storage_provider(db_file.storage_type) + + if db_file.storage_type == "database": + # 从数据库获取文件数据 + content = db_file.file_data or b"" + else: + # 从存储中读取文件 + content = await storage_provider.read(file_id, db_file.storage_path or "") + + return content, db_file.file_name, db_file.content_type or "application/octet-stream" + + @staticmethod + async def delete_file(file_id: int) -> bool: + """删除文件""" + async with async_db_session.begin() as db: + db_file = await file_dao.get(db, file_id) + if not db_file: + return False + + # 删除存储中的文件 + if db_file.storage_type != "database": + storage_provider = get_storage_provider(db_file.storage_type) + await storage_provider.delete(file_id, db_file.storage_path or "") + + # 删除数据库记录 + result = await file_dao.delete(db, file_id) + return result > 0 + + @staticmethod + async def get_file_by_hash(db, file_hash: str) -> Optional[File]: + """通过哈希值获取文件""" + return await file_dao.get_by_hash(db, file_hash) + + @staticmethod + def detect_image_format(image_bytes: bytes) -> ImageFormat: + """通过二进制数据检测图片格式""" + # 使用imghdr识别基础格式 + format_str = imghdr.what(None, h=image_bytes) + + # 映射到枚举类型 + format_mapping = { + 'jpeg': ImageFormat.JPEG, + 'jpg': ImageFormat.JPEG, + 'png': ImageFormat.PNG, + 'gif': ImageFormat.GIF, + 'bmp': ImageFormat.BMP, + 'webp': ImageFormat.WEBP, + 'tiff': ImageFormat.TIFF, + 'svg': ImageFormat.SVG + } + + return format_mapping.get(format_str, ImageFormat.UNKNOWN) + + @staticmethod + def extract_image_metadata(image_bytes: bytes, additional_info: Dict[str, Any] = None) -> ImageMetadata: + """从图片二进制数据中提取元数据""" + try: + with PILImage.open(io.BytesIO(image_bytes)) as img: + # 获取基础信息 + width, height = img.size + color_mode = ColorMode(img.mode) if img.mode in ColorMode.__members__.values() else ColorMode.UNKNOWN + + # 获取EXIF数据 + exif_data = {} + if hasattr(img, '_getexif') and img._getexif(): + for tag, value in img._getexif().items(): + decoded_tag = ExifTags.TAGS.get(tag, tag) + # 特殊处理日期时间 + if decoded_tag in ['DateTime', 'DateTimeOriginal', 'DateTimeDigitized']: + try: + value = datetime.strptime(value, "%Y:%m:%d %H:%M:%S").isoformat() + except: + pass + exif_data[decoded_tag] = value + + # 获取颜色通道数 + channels = len(img.getbands()) + + # 尝试获取DPI + dpi = img.info.get('dpi') + + # 创建元数据对象 + metadata = ImageMetadata( + format=file_service.detect_image_format(image_bytes), + width=width, + height=height, + color_mode=color_mode, + file_size=len(image_bytes), + channels=channels, + dpi=dpi, + exif=exif_data + ) + + # 添加额外信息 + if additional_info: + for key, value in additional_info.items(): + if hasattr(metadata, key): + setattr(metadata, key, value) + + return metadata + except Exception as e: + # 无法解析图片时返回基础元数据 + return ImageMetadata( + format=file_service.detect_image_format(image_bytes), + width=0, + height=0, + color_mode=ColorMode.UNKNOWN, + file_size=len(image_bytes), + error=f"Metadata extraction failed: {str(e)}" + ) + +file_service = FileService() \ No newline at end of file diff --git a/backend/app/admin/service/file_storage.py b/backend/app/admin/service/file_storage.py new file mode 100755 index 0000000..6bf98cc --- /dev/null +++ b/backend/app/admin/service/file_storage.py @@ -0,0 +1,114 @@ +import hashlib +import os +from abc import ABC, abstractmethod +from typing import Optional +import aiofiles +from fastapi import UploadFile + +from backend.core.conf import settings +from backend.core.path_conf import UPLOAD_DIR + + +class StorageProvider(ABC): + """存储提供者抽象基类""" + + @abstractmethod + async def save(self, file_id: int, content: bytes, file_name: str) -> str: + """保存文件""" + pass + + @abstractmethod + async def read(self, file_id: int, storage_path: str) -> bytes: + """读取文件""" + pass + + @abstractmethod + async def delete(self, file_id: int, storage_path: str) -> bool: + """删除文件""" + pass + + +class DatabaseStorage(StorageProvider): + """数据库存储提供者""" + + async def save(self, file_id: int, content: bytes, file_name: str) -> str: + """数据库存储不需要实际保存文件,直接返回空字符串""" + return "" + + async def read(self, file_id: int, storage_path: str) -> bytes: + """数据库存储不需要读取文件""" + return b"" + + async def delete(self, file_id: int, storage_path: str) -> bool: + """数据库存储不需要删除文件""" + return True + + +class LocalStorage(StorageProvider): + """本地文件系统存储提供者""" + + def __init__(self, base_path: str = settings.STORAGE_PATH): + self.base_path = base_path + + def _get_path(self, file_id: int, file_name: str) -> str: + """构建文件路径""" + # 使用文件ID作为目录名,避免单个目录下文件过多 + dir_name = str(file_id // 1000) + file_dir = os.path.join(self.base_path, dir_name) + os.makedirs(file_dir, exist_ok=True) + return os.path.join(file_dir, f"{file_id}_{file_name}") + + async def save(self, file_id: int, content: bytes, file_name: str) -> str: + """保存文件到本地""" + path = self._get_path(file_id, file_name) + async with aiofiles.open(path, 'wb') as f: + await f.write(content) + return path + + async def read(self, file_id: int, storage_path: str) -> bytes: + """从本地读取文件""" + async with aiofiles.open(storage_path, 'rb') as f: + return await f.read() + + async def delete(self, file_id: int, storage_path: str) -> bool: + """从本地删除文件""" + try: + os.remove(storage_path) + return True + except: + return False + + +class S3Storage(StorageProvider): + """AWS S3存储提供者(占位实现)""" + + async def save(self, file_id: int, content: bytes, file_name: str) -> str: + """保存文件到S3""" + # 实际实现需要使用boto3等库连接到S3 + # 这里仅作为示例展示接口 + return f"s3://bucket-name/{file_id}/{file_name}" + + async def read(self, file_id: int, storage_path: str) -> bytes: + """从S3读取文件""" + # 实际实现需要使用boto3等库连接到S3 + return b"" + + async def delete(self, file_id: int, storage_path: str) -> bool: + """从S3删除文件""" + # 实际实现需要使用boto3等库连接到S3 + return True + + +def get_storage_provider(provider_type: str) -> StorageProvider: + """根据配置获取存储提供者""" + if provider_type == "local": + return LocalStorage() + elif provider_type == "s3": + return S3Storage() + else: # 默认使用数据库存储 + return DatabaseStorage() + + +def calculate_file_hash(content: bytes) -> str: + """计算文件的SHA256哈希值""" + return hashlib.sha256(content).hexdigest() \ No newline at end of file diff --git a/backend/app/admin/service/notification_service.py b/backend/app/admin/service/notification_service.py new file mode 100755 index 0000000..04fb1ca --- /dev/null +++ b/backend/app/admin/service/notification_service.py @@ -0,0 +1,146 @@ +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.admin.crud.notification_crud import notification_dao, user_notification_dao +from backend.app.admin.model.notification import Notification, UserNotification +from backend.database.db import async_db_session +from backend.common.exception import errors +from datetime import datetime + + +class NotificationService: + + @staticmethod + async def create_notification(title: str, content: str, image_url: Optional[str] = None, + created_by: Optional[int] = None) -> Notification: + """ + 创建消息通知 + """ + async with async_db_session.begin() as db: + notification_data = { + 'title': title, + 'content': content, + 'image_url': image_url, + 'created_by': created_by + } + + notification = await notification_dao.create_notification(db, notification_data) + return notification + + @staticmethod + async def send_notification_to_user(notification_id: int, user_id: int) -> UserNotification: + """ + 发送通知给指定用户 + """ + async with async_db_session.begin() as db: + # 检查通知是否存在 + notification = await notification_dao.get(db, notification_id) + if not notification: + raise errors.RequestError(msg='通知不存在') + + # 创建用户通知关联 + user_notification_data = { + 'notification_id': notification_id, + 'user_id': user_id + } + + user_notification = await user_notification_dao.create_user_notification(db, user_notification_data) + return user_notification + + @staticmethod + async def send_notification_to_users(notification_id: int, user_ids: List[int]) -> List[UserNotification]: + """ + 批量发送通知给多个用户 + """ + async with async_db_session.begin() as db: + # 检查通知是否存在 + notification = await notification_dao.get(db, notification_id) + if not notification: + raise errors.RequestError(msg='通知不存在') + + # 创建用户通知关联列表 + user_notifications_data = [ + { + 'notification_id': notification_id, + 'user_id': user_id + } + for user_id in user_ids + ] + + user_notifications = await user_notification_dao.create_user_notifications(db, user_notifications_data) + return user_notifications + + @staticmethod + async def get_user_notifications(user_id: int, limit: int = 100) -> List[dict]: + """ + 获取用户的通知列表(包含通知详情) + """ + async with async_db_session() as db: + user_notifications = await user_notification_dao.get_user_notifications(db, user_id, limit) + + notifications = [] + for user_notification in user_notifications: + # 获取通知详情 + notification = await notification_dao.get(db, user_notification.notification_id) + if notification: + notifications.append({ + 'id': user_notification.id, + 'notification_id': notification.id, + 'title': notification.title, + 'content': notification.content, + 'image_url': notification.image_url, + 'is_read': user_notification.is_read, + 'received_at': user_notification.received_at, + 'read_at': user_notification.read_at + }) + + return notifications + + @staticmethod + async def get_unread_notifications(user_id: int, limit: int = 100) -> List[dict]: + """ + 获取用户未读通知列表(包含通知详情) + """ + async with async_db_session() as db: + user_notifications = await user_notification_dao.get_unread_notifications(db, user_id, limit) + + notifications = [] + for user_notification in user_notifications: + # 获取通知详情 + notification = await notification_dao.get(db, user_notification.notification_id) + if notification: + notifications.append({ + 'id': user_notification.id, + 'notification_id': notification.id, + 'title': notification.title, + 'content': notification.content, + 'image_url': notification.image_url, + 'received_at': user_notification.received_at + }) + + return notifications + + @staticmethod + async def mark_notification_as_read(user_notification_id: int, user_id: int) -> bool: + """ + 标记通知为已读 + """ + async with async_db_session.begin() as db: + # 验证用户权限 + user_notification = await user_notification_dao.get_user_notification_by_id(db, user_notification_id) + if not user_notification: + raise errors.RequestError(msg='通知不存在') + + if user_notification.user_id != user_id: + raise errors.ForbiddenError(msg='无权限操作此通知') + + success = await user_notification_dao.mark_as_read(db, user_notification_id) + return success + + @staticmethod + async def get_unread_count(user_id: int) -> int: + """ + 获取用户未读通知数量 + """ + async with async_db_session() as db: + count = await user_notification_dao.get_unread_count(db, user_id) + return count \ No newline at end of file diff --git a/backend/app/admin/service/points_service.py b/backend/app/admin/service/points_service.py new file mode 100644 index 0000000..e555286 --- /dev/null +++ b/backend/app/admin/service/points_service.py @@ -0,0 +1,180 @@ +from typing import Optional +from backend.app.admin.crud.points_crud import points_dao, points_log_dao +from backend.app.admin.model.points import Points +from backend.database.db import async_db_session + + +class PointsService: + @staticmethod + async def get_user_points(user_id: int) -> Optional[Points]: + """ + 获取用户积分账户信息(会检查并清空过期积分) + """ + async with async_db_session.begin() as db: + # 获取当前积分余额(清空前) + points_account_before = await points_dao.get_by_user_id(db, user_id) + balance_before = points_account_before.balance if points_account_before else 0 + + # 检查并清空过期积分 + expired_cleared = await points_dao.check_and_clear_expired_points(db, user_id) + + # 如果清空了过期积分,记录日志 + if expired_cleared and balance_before > 0: + await points_log_dao.add_log(db, { + "user_id": user_id, + "action": "expire_clear", + "amount": balance_before, # 记录清空前的积分数量 + "balance_after": 0, + "details": {"message": "过期积分已清空", "cleared_amount": balance_before} + }) + + return await points_dao.get_by_user_id(db, user_id) + + @staticmethod + async def get_user_balance(user_id: int) -> int: + """ + 获取用户积分余额(会检查并清空过期积分) + """ + async with async_db_session.begin() as db: + # 获取当前积分余额(清空前) + points_account_before = await points_dao.get_by_user_id(db, user_id) + balance_before = points_account_before.balance if points_account_before else 0 + + # 检查并清空过期积分 + expired_cleared = await points_dao.check_and_clear_expired_points(db, user_id) + + # 如果清空了过期积分,记录日志 + if expired_cleared and balance_before > 0: + await points_log_dao.add_log(db, { + "user_id": user_id, + "action": "expire_clear", + "balance_before": balance_before, + "balance_after": 0, + "details": {"message": "过期积分已清空", "cleared_amount": balance_before} + }) + + return await points_dao.get_balance(db, user_id) + + @staticmethod + async def add_points(user_id: int, amount: int, extend_expiration: bool = False, related_id: Optional[int] = None, details: Optional[dict] = None) -> bool: + """ + 为用户增加积分 + + Args: + user_id: 用户ID + amount: 增加的积分数量 + extend_expiration: 是否自动延期过期时间 + related_id: 关联ID(可选) + details: 附加信息(可选) + + Returns: + bool: 是否成功 + """ + if amount <= 0: + raise ValueError("积分数量必须大于0") + + async with async_db_session.begin() as db: + # 获取当前余额以记录日志 + points_account = await points_dao.get_by_user_id(db, user_id) + if not points_account: + points_account = await points_dao.create_user_points(db, user_id) + + current_balance = points_account.balance + + # 原子性增加积分(可能延期过期时间) + result = await points_dao.add_points_atomic(db, user_id, amount, extend_expiration) + if not result: + return False + + # 准备日志详情 + log_details = details or {} + if extend_expiration: + log_details["expiration_extended"] = True + log_details["extension_days"] = 30 + + # 记录积分变动日志 + new_balance = current_balance + amount + await points_log_dao.add_log(db, { + "user_id": user_id, + "action": "earn", + "amount": amount, + "balance_after": new_balance, + "related_id": related_id, + "details": log_details + }) + + return True + + @staticmethod + async def deduct_points(user_id: int, amount: int, related_id: Optional[int] = None, details: Optional[dict] = None) -> bool: + """ + 扣减用户积分(会检查并清空过期积分) + + Args: + user_id: 用户ID + amount: 扣减的积分数量 + related_id: 关联ID(可选) + details: 附加信息(可选) + + Returns: + bool: 是否成功 + """ + if amount <= 0: + raise ValueError("积分数量必须大于0") + + async with async_db_session.begin() as db: + # 获取当前积分余额(清空前) + points_account_before = await points_dao.get_by_user_id(db, user_id) + if not points_account_before: + return False + + balance_before = points_account_before.balance + + # 检查并清空过期积分 + expired_cleared = await points_dao.check_and_clear_expired_points(db, user_id) + + # 如果清空了过期积分,记录日志 + if expired_cleared and balance_before > 0: + await points_log_dao.add_log(db, { + "user_id": user_id, + "action": "expire_clear", + "amount": balance_before, # 记录清空前的积分数量 + "balance_after": 0, + "details": {"message": "过期积分已清空", "cleared_amount": balance_before} + }) + + # 重新获取账户信息(可能已被清空) + points_account = await points_dao.get_by_user_id(db, user_id) + if not points_account or points_account.balance < amount: + return False + + current_balance = points_account.balance + + # 原子性扣减积分 + result = await points_dao.deduct_points_atomic(db, user_id, amount) + if not result: + return False + + # 记录积分变动日志 + new_balance = current_balance - amount + await points_log_dao.add_log(db, { + "user_id": user_id, + "action": "spend", + "amount": amount, + "balance_after": new_balance, + "related_id": related_id, + "details": details + }) + + return True + + @staticmethod + async def initialize_user_points(user_id: int) -> Points: + """ + 为新用户初始化积分账户 + """ + async with async_db_session.begin() as db: + points_account = await points_dao.get_by_user_id(db, user_id) + if not points_account: + points_account = await points_dao.create_user_points(db, user_id) + return points_account \ No newline at end of file diff --git a/backend/app/admin/service/refund_service.py b/backend/app/admin/service/refund_service.py new file mode 100755 index 0000000..29164c0 --- /dev/null +++ b/backend/app/admin/service/refund_service.py @@ -0,0 +1,233 @@ +from backend.app.admin.crud.freeze_log_crud import freeze_log_dao +from backend.app.admin.crud.user_account_crud import user_account_dao +from backend.app.admin.crud.order_crud import order_dao +from backend.app.admin.crud.usage_log_crud import usage_log_dao +from backend.app.admin.model.order import FreezeLog + +from backend.app.admin.model import UserAccount +from backend.database.db import async_db_session +from wechatpy.pay import WeChatPay +from backend.core.conf import settings +from backend.common.log import log as logger +from backend.common.exception import errors +from sqlalchemy import select, update +from datetime import datetime, timedelta +from fastapi import Request + +from backend.utils.wx_pay import wx_pay_utils + +wxpay = WeChatPay( + appid=settings.WX_APPID, + api_key=settings.WX_SECRET, + mch_id=settings.WX_MCH_ID, + mch_cert=settings.WX_PAY_CERT_PATH, + mch_key=settings.WX_PAY_KEY_PATH +) + + +class RefundService: + + @staticmethod + async def freeze_times_for_refund(user_id: int, order_id: int, reason: str = None): + """ + 为退款冻结次数(增强幂等性和安全性) + """ + async with async_db_session.begin() as db: + # 检查是否已有pending的退款申请(幂等性) + existing_stmt = select(FreezeLog).where( + FreezeLog.order_id == order_id, + FreezeLog.status == "pending" + ) + existing_result = await db.execute(existing_stmt) + if existing_result.scalar_one_or_none(): + raise errors.RequestError(msg="该订单已有待处理的退款申请") + + order = await order_dao.get_by_id(db, order_id) + if not order: + raise errors.NotFoundError(msg="订单不存在") + + if order.status != 'completed': + raise errors.RequestError(msg="订单状态异常,无法退款") + + if order.user_id != user_id: + raise errors.ForbiddenError(msg="无权操作该订单") + + # 检查退款时效(7天内) + if datetime.now() - order.created_at > timedelta(days=7): + raise errors.RequestError(msg="超过退款时效(7天)") + + # 原子性冻结次数 + result = await db.execute( + update(UserAccount) + .where(UserAccount.user_id == user_id) + .where(UserAccount.balance >= order.amount_times) + .values(balance=UserAccount.balance - order.amount_times) + ) + + if result.rowcount == 0: + raise errors.ForbiddenError(msg="余额不足或并发冲突") + + # 创建冻结记录 + freeze_log = FreezeLog( + user_id=user_id, + order_id=order_id, + amount=order.amount_times, + reason=reason or "用户申请退款", + status="pending" + ) + await freeze_log_dao.add(db, freeze_log) + + # 记录使用日志 + account = await user_account_dao.get_by_user_id(db, user_id) + await usage_log_dao.add(db, { + "user_id": user_id, + "action": "freeze", + "amount": -order.amount_times, + "balance_after": account.balance, + "related_id": freeze_log.id, + "details": {"order_id": order_id, "reason": reason} + }) + + return freeze_log + + @staticmethod + async def process_refund(user_id: int, order_id: int, refund_desc: str = "用户申请退款"): + """ + 处理微信退款(增强安全性和幂等性) + """ + async with async_db_session() as db: + # 先冻结次数 + freeze_log = await RefundService.freeze_times_for_refund(user_id, order_id, refund_desc) + + try: + order = await order_dao.get_by_id(db, order_id) + + # 调用微信退款接口 + refund_result = wxpay.refund.apply( + out_trade_no=str(order_id), + out_refund_no=str(freeze_log.id), # 使用冻结记录ID作为退款单号 + total_fee=order.amount_cents, + refund_fee=order.amount_cents, + refund_desc=refund_desc + ) + + if refund_result['return_code'] == 'SUCCESS' and refund_result['result_code'] == 'SUCCESS': + # 更新冻结记录状态 + async with async_db_session.begin() as update_db: + freeze_log.status = "confirmed" + await freeze_log_dao.update(update_db, freeze_log.id, freeze_log) + return {"status": "success", "refund_id": refund_result.get('refund_id')} + else: + # 退款失败,取消冻结 + await RefundService.cancel_freeze(freeze_log.id) + raise errors.ServerError( + msg=f"微信退款失败: {refund_result.get('err_code_des', '未知错误')}") + + except Exception as e: + # 发生异常时也要取消冻结 + await RefundService.cancel_freeze(freeze_log.id) + raise e + + @staticmethod + async def cancel_freeze(freeze_id: int): + """ + 取消冻结(退款失败时调用) + """ + async with async_db_session.begin() as db: + freeze_log = await freeze_log_dao.get_by_id(db, freeze_id) + if not freeze_log or freeze_log.status != "pending": + return + + freeze_log.status = "cancelled" + await freeze_log_dao.update(db, freeze_log.id, freeze_log) + + # 原子性恢复用户余额 + result = await db.execute( + update(UserAccount) + .where(UserAccount.user_id == freeze_log.user_id) + .values(balance=UserAccount.balance + freeze_log.amount) + ) + + if result.rowcount == 0: + logger.error(f"恢复冻结次数失败: freeze_id={freeze_id}") + return + + # 记录使用日志 + account = await user_account_dao.get_by_user_id(db, freeze_log.user_id) + await usage_log_dao.add(db, { + "user_id": freeze_log.user_id, + "action": "unfreeze", + "amount": freeze_log.amount, + "balance_after": account.balance, + "related_id": freeze_log.id, + "details": {"reason": "退款失败,恢复次数"} + }) + + @staticmethod + async def handle_refund_notify(request: Request): + """ + 处理微信退款回调(增强安全性和幂等性) + """ + try: + body = await request.body() + result = wx_pay_utils.parse_payment_result(body) + + # 验证签名 + if not wx_pay_utils.verify_wxpay_signature(result.copy(), settings.WECHAT_PAY_API_KEY): + logger.warning("微信退款回调签名验证失败") + return {"return_code": "FAIL", "return_msg": "签名验证失败"} + + if result['return_code'] == 'SUCCESS': + out_refund_no = result['out_refund_no'] # 对应freeze_log.id + refund_status = result.get('refund_status_0', 'UNKNOWN') # 假设只有一个退款 + + async with async_db_session.begin() as db: + # 使用SELECT FOR UPDATE确保并发安全 + stmt = select(FreezeLog).where(FreezeLog.id == int(out_refund_no)).with_for_update() + freeze_result = await db.execute(stmt) + freeze_log = freeze_result.scalar_one_or_none() + + if not freeze_log: + logger.warning(f"冻结记录不存在: {out_refund_no}") + return {"return_code": "SUCCESS"} + + # 幂等性检查 + if freeze_log.status in ["confirmed", "cancelled"]: + logger.info(f"冻结记录已处理过: {out_refund_no}") + return {"return_code": "SUCCESS"} + + if refund_status == 'SUCCESS': + freeze_log.status = "confirmed" + await freeze_log_dao.update(db, freeze_log.id, freeze_log) + + # 从总购买次数中扣除 + account_stmt = select(UserAccount).where( + UserAccount.user_id == freeze_log.user_id).with_for_update() + account_result = await db.execute(account_stmt) + user_account = account_result.scalar_one_or_none() + + if user_account: + user_account.total_purchased = max(0, user_account.total_purchased - freeze_log.amount) + await user_account_dao.update(db, user_account.id, user_account) + + # 记录使用日志 + await usage_log_dao.add(db, { + "user_id": freeze_log.user_id, + "action": "refund", + "amount": -freeze_log.amount, + "balance_after": user_account.balance if user_account else 0, + "related_id": freeze_log.id, + "details": {"refund_id": result.get('refund_id_0')} + }) + elif refund_status in ['FAIL', 'CHANGE']: + # 退款失败,取消冻结 + await RefundService.cancel_freeze(freeze_log.id) + + return {"return_code": "SUCCESS"} + else: + logger.error(f"微信退款回调失败: {result}") + return {"return_code": "FAIL", "return_msg": "处理失败"} + + except Exception as e: + logger.error(f"微信退款回调处理异常: {str(e)}") + return {"return_code": "FAIL", "return_msg": "服务器异常"} \ No newline at end of file diff --git a/backend/app/admin/service/storage.py b/backend/app/admin/service/storage.py new file mode 100755 index 0000000..41405b8 --- /dev/null +++ b/backend/app/admin/service/storage.py @@ -0,0 +1,44 @@ +import os +import aiofiles +from pathlib import Path +from abc import ABC, abstractmethod +from fastapi import UploadFile + +from backend.core.conf import settings +from backend.core.path_conf import UPLOAD_DIR + + +class StorageProvider(ABC): + @abstractmethod + async def save(self, file_id: int, content: bytes): + pass + + @abstractmethod + async def read(self, file_id: int) -> bytes: + pass + + +class LocalStorage(StorageProvider): + def __init__(self, base_path: str = UPLOAD_DIR): + self.base_path = base_path + + def _get_path(self, file_id: int) -> str: + """使用 os.path.join 构建文件路径""" + return os.path.join(self.base_path, str(file_id)) + + async def save(self, file_id: int, content: bytes): + path = self._get_path(file_id) + # 确保目录存在 + os.makedirs(os.path.dirname(path), exist_ok=True) + + async with aiofiles.open(path, 'wb') as f: + await f.write(content) + + async def read(self, file_id: int) -> bytes: + path = self._get_path(file_id) + async with aiofiles.open(path, 'rb') as f: + return await f.read() + + +# 未来可添加S3存储 +# class S3Storage(StorageProvider): ... \ No newline at end of file diff --git a/backend/app/admin/service/subscription_service.py b/backend/app/admin/service/subscription_service.py new file mode 100755 index 0000000..99eeab3 --- /dev/null +++ b/backend/app/admin/service/subscription_service.py @@ -0,0 +1,42 @@ +from datetime import timedelta, datetime + +from backend.app.admin.crud.usage_log_crud import usage_log_dao +from backend.app.admin.crud.user_account_crud import user_account_dao +from backend.app.admin.service.usage_service import UsageService +from backend.common.exception import errors + +from backend.database.db import async_db_session + +SUBSCRIPTION_PLANS = { + "monthly": {"price": 1290, "times": 300, "duration": timedelta(days=30)}, + "quarterly": {"price": 2990, "times": 900, "duration": timedelta(days=90)}, + "half_yearly": {"price": 3990, "times": 1800, "duration": timedelta(days=180)}, + "yearly": {"price": 6990, "times": 3600, "duration": timedelta(days=365)}, +} + +class SubscriptionService: + + @staticmethod + async def subscribe(user_id: int, plan_key: str): + plan = SUBSCRIPTION_PLANS.get(plan_key) + if not plan: + raise errors.RequestError(msg="无效订阅计划") + + async with async_db_session.begin() as db: + account = await UsageService.get_user_account(user_id) + # 处理未用完次数 + account.balance += account.carryover_balance + account.carryover_balance = 0 + # 更新订阅信息 + account.subscription_type = plan_key + account.subscription_expires_at = datetime.now() + plan["duration"] + account.balance += plan["times"] + await user_account_dao.update(db, account.id, account) + + await usage_log_dao.add(db, { + "user_id": user_id, + "action": "renewal", + "amount": plan["times"], + "balance_after": account.balance, + "details": {"plan": plan_key} + }) \ No newline at end of file diff --git a/backend/app/admin/service/usage_service.py b/backend/app/admin/service/usage_service.py new file mode 100755 index 0000000..9f21154 --- /dev/null +++ b/backend/app/admin/service/usage_service.py @@ -0,0 +1,214 @@ +from decimal import Decimal, ROUND_HALF_UP +from backend.app.admin.crud.user_account_crud import user_account_dao +from backend.app.admin.crud.usage_log_crud import usage_log_dao +from backend.app.admin.crud.order_crud import order_dao +from backend.app.admin.model.order import UserAccount +from backend.app.admin.model.order import Order +from backend.app.admin.schema.usage import PurchaseRequest +from backend.common.exception import errors +from backend.database.db import async_db_session +from datetime import datetime, timedelta +from sqlalchemy import func, select, update + + +class UsageService: + + @staticmethod + async def get_user_account(user_id: int) -> UserAccount: + async with async_db_session() as db: + account = await user_account_dao.get_by_user_id(db, user_id) + if not account: + account = UserAccount(user_id=user_id) + await user_account_dao.add(db, account) + return account + + @staticmethod + def calculate_purchase_times_safe(amount_cents: int) -> int: + """ + 安全的充值次数计算(使用Decimal避免浮点数精度问题) + """ + if amount_cents <= 0: + raise ValueError("充值金额必须大于0") + + # 限制最大充值金额(防止溢出) + if amount_cents > 10000000: # 10万元 + raise ValueError("单次充值金额不能超过10万元") + + amount_yuan = Decimal(amount_cents) / Decimal(100) + base_times = amount_yuan * Decimal(10) + + # 计算优惠比例(每10元增加10%,最多100%) + tens = (amount_yuan // Decimal(10)) + bonus_percent = min(tens * Decimal('0.1'), Decimal('1.0')) + + total_times = base_times * (Decimal('1') + bonus_percent) + return int(total_times.quantize(Decimal('1'), rounding=ROUND_HALF_UP)) + + @staticmethod + async def purchase_times(user_id: int, request: PurchaseRequest): + # 输入验证 + if request.amount_cents < 100: # 最少1元 + raise errors.RequestError(msg="充值金额不能少于1元") + if request.amount_cents > 10000000: # 最多10万元 + raise errors.RequestError(msg="单次充值金额不能超过10万元") + + async with async_db_session.begin() as db: + account = await UsageService.get_user_account(user_id) + times = UsageService.calculate_purchase_times_safe(request.amount_cents) + + order = Order( + user_id=user_id, + order_type="purchase", + amount_cents=request.amount_cents, + amount_times=times, + status="pending" + ) + await order_dao.add(db, order) + await db.flush() # 获取order.id + + # 原子性更新账户(防止并发问题) + result = await db.execute( + update(UserAccount) + .where(UserAccount.id == account.id) + .values( + balance=UserAccount.balance + times, + total_purchased=UserAccount.total_purchased + times + ) + ) + + if result.rowcount == 0: + raise errors.ServerError(msg="账户更新失败") + + # 更新订单状态 + order.status = "completed" + order.processed_at = datetime.now() + await order_dao.update(db, order.id, order) + + await usage_log_dao.add(db, { + "user_id": user_id, + "action": "purchase", + "amount": times, + "balance_after": account.balance + times, + "related_id": order.id, + "details": {"amount_cents": request.amount_cents} + }) + + @staticmethod + async def use_times_atomic(user_id: int, count: int = 1): + """ + 原子性扣减次数,支持免费试用优先使用 + """ + if count <= 0: + raise ValueError("扣减次数必须大于0") + + async with async_db_session.begin() as db: + account = await UsageService.get_user_account(user_id) + + # 检查免费试用是否有效 + is_free_trial_valid = ( + account.free_trial_expires_at and + account.free_trial_expires_at > datetime.now() and + account.free_trial_balance > 0 + ) + + if is_free_trial_valid: + # 优先使用免费试用次数 + free_trial_deduct = min(count, account.free_trial_balance) + remaining_count = count - free_trial_deduct + + # 更新免费试用余额 + if free_trial_deduct > 0: + await db.execute( + update(UserAccount) + .where(UserAccount.id == account.id) + .values( + free_trial_balance=UserAccount.free_trial_balance - free_trial_deduct, + balance=UserAccount.balance - free_trial_deduct + ) + ) + + # 如果还有剩余次数,从普通余额中扣除 + if remaining_count > 0: + result = await db.execute( + update(UserAccount) + .where(UserAccount.id == account.id) + .where(UserAccount.balance >= remaining_count) + .values(balance=UserAccount.balance - remaining_count) + ) + + if result.rowcount == 0: + # 恢复免费试用余额 + await db.execute( + update(UserAccount) + .where(UserAccount.id == account.id) + .values( + free_trial_balance=UserAccount.free_trial_balance + free_trial_deduct, + balance=UserAccount.balance + free_trial_deduct + ) + ) + raise errors.ForbiddenError(msg="余额不足") + else: + # 直接从普通余额中扣除 + result = await db.execute( + update(UserAccount) + .where(UserAccount.id == account.id) + .where(UserAccount.balance >= count) + .values(balance=UserAccount.balance - count) + ) + + if result.rowcount == 0: + raise errors.ForbiddenError(msg="余额不足") + + # 记录使用日志 + updated_account = await user_account_dao.get_by_id(db, account.id) + await usage_log_dao.add(db, { + "user_id": user_id, + "action": "use", + "amount": -count, + "balance_after": updated_account.balance if updated_account else 0, + "metadata_": { + "used_at": datetime.now().isoformat(), + "is_free_trial": is_free_trial_valid + } + }) + + @staticmethod + async def get_account_info(user_id: int) -> dict: + """ + 获取用户账户详细信息 + """ + async with async_db_session() as db: + account = await UsageService.get_user_account(user_id) + if not account: + return {} + + frozen_balance = await user_account_dao.get_frozen_balance(db, user_id) + available_balance = max(0, account.balance - frozen_balance) + + # 检查免费试用状态 + is_free_trial_active = ( + account.free_trial_expires_at and + account.free_trial_expires_at > datetime.now() + ) + + return { + "balance": account.balance, + "available_balance": available_balance, + "frozen_balance": frozen_balance, + "total_purchased": account.total_purchased, + "subscription_type": account.subscription_type, + "subscription_expires_at": account.subscription_expires_at, + "carryover_balance": account.carryover_balance, + + # 免费试用信息 + "free_trial_balance": account.free_trial_balance, + "free_trial_expires_at": account.free_trial_expires_at, + "free_trial_active": is_free_trial_active, + "free_trial_used": account.free_trial_used, + + # 计算剩余天数 + "free_trial_days_left": ( + max(0, (account.free_trial_expires_at - datetime.now()).days) + if account.free_trial_expires_at else 0 + ) + } \ No newline at end of file diff --git a/backend/app/admin/service/wx_service.py b/backend/app/admin/service/wx_service.py new file mode 100755 index 0000000..fed5c8e --- /dev/null +++ b/backend/app/admin/service/wx_service.py @@ -0,0 +1,128 @@ +# services/wx.py +from sqlalchemy.orm import Session +from typing import Optional + +from fastapi import Request, Response +from backend.app.admin.crud.wx_user_crud import wx_user_dao +from backend.app.admin.model.wx_user import WxUser +from backend.app.admin.schema.token import GetWxLoginToken +from backend.app.admin.schema.wx import DictLevel +from backend.common.security.jwt import create_access_token, create_refresh_token +from backend.core.conf import settings +import logging + +from backend.app.admin.service.wx_user_service import WxUserService +from backend.core.wx_integration import decrypt_wx_data +from backend.database.db import async_db_session +from backend.utils.timezone import timezone + + +class WxAuthService: + @staticmethod + async def login( + *, + request: Request, response: Response, + openid: str, session_key: str, + encrypted_data: str = None, + iv: str = None + ) -> GetWxLoginToken: + """ + 处理用户登录逻辑: + 1. 查找或创建用户 + 2. 更新用户session_key + 3. 解密用户信息(如果提供) + 4. 生成访问令牌 + """ + async with async_db_session.begin() as db: + user = None + try: + # 查找或创建用户 + user = await wx_user_dao.get_by_openid(db, openid) + if not user: + user = WxUser( + openid=openid, + session_key=session_key, + profile={'dict_level': DictLevel.LEVEL1.value}, + ) + await wx_user_dao.add(db, user) + await db.flush() + await db.refresh(user) + else: + await wx_user_dao.update_session_key(db, user.id, session_key) + + + # 解密用户信息(如果提供) + if encrypted_data and iv: + try: + decrypted_data = decrypt_wx_data( + encrypted_data, + session_key, + iv + ) + WxUserService.update_user_profile(db, user.id, decrypted_data) + except Exception as e: + logging.warning(f"用户数据解密失败: {str(e)}") + + # 生成访问令牌 + access_token = await create_access_token( + user.id, + False, + # extra info + ip=request.client.host, + # os=request.state.os, + # browser=request.state.browser, + # device=request.state.device, + ) + refresh_token = await create_refresh_token(access_token.session_uuid, user.id, False) + response.set_cookie( + key=settings.COOKIE_REFRESH_TOKEN_KEY, + value=refresh_token.refresh_token, + max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS, + expires=timezone.to_utc(refresh_token.refresh_token_expire_time), + httponly=True, + ) + except Exception as e: + db.rollback() + logging.error(f"登录处理失败: {str(e)}") + raise + else: + # 从用户资料中获取词典等级设置 + dict_level = None + if user and user.profile and isinstance(user.profile, dict): + dict_level = user.profile.get("dict_level") + + data = GetWxLoginToken( + access_token=access_token.access_token, + access_token_expire_time=access_token.access_token_expire_time, + session_uuid=access_token.session_uuid, + dict_level=dict_level + ) + return data + + @staticmethod + async def update_user_settings( + *, + user_id: int, + dict_level: Optional[DictLevel] = None + ) -> None: + """ + 更新用户设置 + """ + async with async_db_session.begin() as db: + user = await wx_user_dao.get(db, user_id) + if not user: + raise ValueError("用户不存在") + + # 如果用户没有profile,初始化为空字典 + if not user.profile: + user.profile = {} + + # 更新词典等级设置 + if dict_level is not None: + user.profile["dict_level"] = dict_level.value + + # 使用新的方法更新用户资料,会自动更新updated_time字段 + await wx_user_dao.update_user_profile(db, user_id, user.profile) + + +wx_service: WxAuthService = WxAuthService() \ No newline at end of file diff --git a/backend/app/admin/service/wx_user_service.py b/backend/app/admin/service/wx_user_service.py new file mode 100755 index 0000000..aa8fbc9 --- /dev/null +++ b/backend/app/admin/service/wx_user_service.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from sqlalchemy import Select +from sqlalchemy.orm import Session + +from backend.common.exception import errors +from backend.common.security.jwt import superuser_verify, password_verify, get_hash_password +from backend.app.admin.crud.wx_user_crud import wx_user_dao +from backend.database.db import async_db_session +from backend.app.admin.model import WxUser +from backend.app.admin.schema.user import RegisterUserParam, ResetPassword, UpdateUserParam, AvatarParam + + +class WxUserService: + + + @staticmethod + def update_user_profile(db: Session, user_id: int, profile_data: dict): + """ + 更新用户资料 + """ + user = db.query(WxUser).get(user_id) + if not user: + raise ValueError("用户不存在") + + # 保存用户资料,保留现有设置 + current_profile = user.profile or {} + + # 更新微信用户信息 + if "nickName" in profile_data: + current_profile["nickname"] = profile_data.get("nickName") + if "avatarUrl" in profile_data: + current_profile["avatar"] = profile_data.get("avatarUrl") + if "gender" in profile_data: + current_profile["gender"] = profile_data.get("gender") + if "city" in profile_data: + current_profile["city"] = profile_data.get("city") + if "province" in profile_data: + current_profile["province"] = profile_data.get("province") + if "country" in profile_data: + current_profile["country"] = profile_data.get("country") + + user.profile = current_profile + + db.commit() + return user + + @staticmethod + def get_user_info(db: Session, user_id: int): + """ + 获取用户信息(解密敏感数据) + """ + user = db.query(WxUser).get(user_id) + if not user: + return None + + # 解密手机号 + # encryptor = DataEncrypt() + # mobile = encryptor.decrypt(user.mobile) if user.mobile else None + + return { + "id": user.id, + "openid": user.openid, + "mobile": user.mobile, + "profile": user.profile, + "created_at": user.created_at + } + + # @staticmethod + # async def register(*, obj: RegisterUserParam) -> None: + # async with async_db_session.begin() as db: + # if not obj.password: + # raise errors.ForbiddenError(msg='密码为空') + # username = await user_dao.get_by_username(db, obj.username) + # if username: + # raise errors.ForbiddenError(msg='用户已注册') + # email = await user_dao.check_email(db, obj.email) + # if email: + # raise errors.ForbiddenError(msg='邮箱已注册') + # await user_dao.create(db, obj) + # + # @staticmethod + # async def pwd_reset(*, obj: ResetPassword) -> int: + # async with async_db_session.begin() as db: + # user = await user_dao.get_by_username(db, obj.username) + # if not password_verify(obj.old_password, user.password): + # raise errors.ForbiddenError(msg='原密码错误') + # np1 = obj.new_password + # np2 = obj.confirm_password + # if np1 != np2: + # raise errors.ForbiddenError(msg='密码输入不一致') + # new_pwd = get_hash_password(obj.new_password, user.salt) + # count = await user_dao.reset_password(db, user.id, new_pwd) + # return count + # + # @staticmethod + # async def get_userinfo(*, username: str) -> User: + # async with async_db_session() as db: + # user = await user_dao.get_by_username(db, username) + # if not user: + # raise errors.NotFoundError(msg='用户不存在') + # return user + # + # @staticmethod + # async def update(*, username: str, obj: UpdateUserParam) -> int: + # async with async_db_session.begin() as db: + # input_user = await user_dao.get_by_username(db, username=username) + # if not input_user: + # raise errors.NotFoundError(msg='用户不存在') + # superuser_verify(input_user) + # if input_user.username != obj.username: + # _username = await user_dao.get_by_username(db, obj.username) + # if _username: + # raise errors.ForbiddenError(msg='用户名已注册') + # if input_user.email != obj.email: + # email = await user_dao.check_email(db, obj.email) + # if email: + # raise errors.ForbiddenError(msg='邮箱已注册') + # count = await user_dao.update_userinfo(db, input_user.id, obj) + # return count + # + # @staticmethod + # async def update_avatar(*, username: str, avatar: AvatarParam) -> int: + # async with async_db_session.begin() as db: + # input_user = await user_dao.get_by_username(db, username) + # if not input_user: + # raise errors.NotFoundError(msg='用户不存在') + # count = await user_dao.update_avatar(db, input_user.id, avatar) + # return count + # + # @staticmethod + # async def get_select(*, username: str = None, phone: str = None, status: int = None) -> Select: + # return await user_dao.get_list(username=username, phone=phone, status=status) + # + # @staticmethod + # async def delete(*, current_user: User, username: str) -> int: + # async with async_db_session.begin() as db: + # superuser_verify(current_user) + # input_user = await user_dao.get_by_username(db, username) + # if not input_user: + # raise errors.NotFoundError(msg='用户不存在') + # count = await user_dao.delete(db, input_user.id) + # return count \ No newline at end of file diff --git a/backend/app/admin/service/wxpay_service.py b/backend/app/admin/service/wxpay_service.py new file mode 100755 index 0000000..89bb48e --- /dev/null +++ b/backend/app/admin/service/wxpay_service.py @@ -0,0 +1,37 @@ +from wechatpy.pay import WeChatPay + +from backend.app.admin.model.order import Order +from backend.app.admin.service.usage_service import UsageService +from backend.core.conf import settings +from backend.app.admin.crud.order_crud import order_dao +from backend.database.db import async_db_session + +wxpay = WeChatPay( + appid=settings.WECHAT_APP_ID, + api_key=settings.WECHAT_PAY_API_KEY, + mch_id=settings.WECHAT_MCH_ID, + mch_cert=settings.WECHAT_PAY_CERT_PATH, + mch_key=settings.WECHAT_PAY_KEY_PATH +) + +class WxPayService: + + @staticmethod + async def create_order(user_id: int, amount_cents: int, description: str): + async with async_db_session.begin() as db: + order = Order( + user_id=user_id, + order_type="purchase", + amount_cents=amount_cents, + amount_times=UsageService.calculate_purchase_times(amount_cents), + status="pending" + ) + await order_dao.add(db, order) + result = wxpay.order.create( + trade_type="JSAPI", + body=description, + total_fee=amount_cents, + notify_url=settings.WECHAT_NOTIFY_URL, + out_trade_no=str(order.id) + ) + return result \ No newline at end of file diff --git a/backend/app/admin/tasks.py b/backend/app/admin/tasks.py new file mode 100755 index 0000000..c2ccbc8 --- /dev/null +++ b/backend/app/admin/tasks.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +定时任务处理模块 +""" +from datetime import datetime, timedelta +from sqlalchemy import select, and_, desc +from backend.app.admin.model.audit_log import AuditLog, DailySummary +from backend.app.ai.model.image import Image +from backend.app.admin.model.wx_user import WxUser +from backend.app.admin.crud.daily_summary_crud import daily_summary_dao +from backend.app.admin.schema.audit_log import CreateDailySummaryParam +from backend.database.db import async_db_session + + +async def wx_user_index_history() -> None: + """异步实现 wx_user_index_history 任务""" + # 计算前一天的时间范围 + today = datetime.now().date() + yesterday = today - timedelta(days=1) + yesterday_start = datetime(yesterday.year, yesterday.month, yesterday.day) + yesterday_end = datetime(today.year, today.month, today.day) + + async with async_db_session.begin() as db: + # 优化:通过 audit_log 表查询有相关记录的用户,避免遍历所有用户 + # 先获取有前一天 recognition 记录的用户 ID 列表 + user_ids_stmt = ( + select(AuditLog.user_id) + .where( + and_( + AuditLog.api_type == 'recognition', + AuditLog.called_at >= yesterday_start, + AuditLog.called_at < yesterday_end + ) + ) + .distinct() + ) + + user_ids_result = await db.execute(user_ids_stmt) + user_ids = [row[0] for row in user_ids_result.fetchall()] + + if not user_ids: + return # 没有用户有相关记录,直接返回 + + # 分批处理用户,避免一次性加载过多数据 + batch_size = 500 + for i in range(0, len(user_ids), batch_size): + batch_user_ids = user_ids[i:i + batch_size] + + # 为这批用户获取 audit_log 记录 + audit_logs_stmt = ( + select(AuditLog) + .where( + and_( + AuditLog.user_id.in_(batch_user_ids), + AuditLog.api_type == 'recognition', + AuditLog.called_at >= yesterday_start, + AuditLog.called_at < yesterday_end + ) + ) + .order_by(AuditLog.user_id, desc(AuditLog.called_at)) + ) + + audit_logs_result = await db.execute(audit_logs_stmt) + all_audit_logs = audit_logs_result.scalars().all() + + # 按用户 ID 分组 audit_logs + user_audit_logs = {} + for log in all_audit_logs: + if log.user_id not in user_audit_logs: + user_audit_logs[log.user_id] = [] + user_audit_logs[log.user_id].append(log) + + # 获取这批用户的信息 + users_stmt = select(WxUser).where(WxUser.id.in_(batch_user_ids)) + users_result = await db.execute(users_stmt) + users = {user.id: user for user in users_result.scalars().all()} + + # 获取所有相关的 image 记录 + all_image_ids = [log.image_id for log in all_audit_logs if log.image_id] + image_map = {} + if all_image_ids: + images_stmt = select(Image).where(Image.id.in_(all_image_ids)) + images_result = await db.execute(images_stmt) + images = images_result.scalars().all() + image_map = {img.id: img for img in images} + + # 处理每个用户 + for user_id, audit_logs in user_audit_logs.items(): + user = users.get(user_id) + if not user: + continue + + # 构建 ref_word 数据 + image_ids = [] + thumbnail_ids = [] + + for log in audit_logs: + if log.image_id and log.image_id in image_map: + image = image_map[log.image_id] + # 获取用户词典等级 + dict_level = log.dict_level or "default" + + # 从 image.details 中提取 ref_word + ref_words = [] + try: + if image.details and isinstance(image.details, dict): + recognition_result = image.details.get("recognition_result", {}) + if isinstance(recognition_result, dict): + dict_level_data = recognition_result.get(dict_level, {}) + if isinstance(dict_level_data, dict): + ref_word = dict_level_data.get("ref_word") + if ref_word: + if isinstance(ref_word, list): + ref_words = ref_word + else: + ref_words = [str(ref_word)] + except Exception: + # 如果解析出错,跳过该记录 + pass + + # 收集图片ID和缩略图ID用于DailySummary + image_ids.append(str(log.image_id)) + if image.thumbnail_id: + thumbnail_ids.append(str(image.thumbnail_id)) + else: + thumbnail_ids.append('') + + # 创建或更新DailySummary记录 + daily_summary_data = { + "user_id": user_id, + "image_ids": image_ids, + "thumbnail_ids": thumbnail_ids, + "summary_time": yesterday_start + } + + # 检查是否已存在该用户当天的记录 + existing_summary_stmt = ( + select(DailySummary) + .where( + and_( + DailySummary.user_id == user_id, + DailySummary.summary_time == yesterday_start + ) + ) + ) + existing_summary_result = await db.execute(existing_summary_stmt) + existing_summary = existing_summary_result.scalar_one_or_none() + + if existing_summary: + # 更新现有记录 + await daily_summary_dao.update_model(db, existing_summary.id, daily_summary_data) + else: + # 创建新记录 + await daily_summary_dao.create_model(db, CreateDailySummaryParam(**daily_summary_data)) + + # 提交批次更改 + await db.commit() \ No newline at end of file diff --git a/backend/app/ai/__init__.py b/backend/app/ai/__init__.py new file mode 100644 index 0000000..13ac351 --- /dev/null +++ b/backend/app/ai/__init__.py @@ -0,0 +1,4 @@ +from backend.app.ai.model import Image, ImageText #, Article, ArticleParagraph, ArticleSentence +from backend.app.ai.schema import * +from backend.app.ai.crud import image_dao, image_text_dao #, article_dao, article_paragraph_dao, article_sentence_dao +from backend.app.ai.service import ImageService, image_service, ImageTextService, image_text_service #, ArticleService, article_service \ No newline at end of file diff --git a/backend/app/ai/api/__init__.py b/backend/app/ai/api/__init__.py new file mode 100644 index 0000000..3f8ebc0 --- /dev/null +++ b/backend/app/ai/api/__init__.py @@ -0,0 +1 @@ +from backend.app.ai.api.image import router as image_router \ No newline at end of file diff --git a/backend/app/ai/api/article.py b/backend/app/ai/api/article.py new file mode 100644 index 0000000..e25c3a3 --- /dev/null +++ b/backend/app/ai/api/article.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import List +from fastapi import APIRouter, Request, Query +from starlette.background import BackgroundTasks + +from backend.app.ai.schema.article import ArticleSchema, ArticleWithParagraphsSchema, CreateArticleParam, UpdateArticleParam, ArticleParagraphSchema, CreateArticleParagraphParam, UpdateArticleParagraphParam, ArticleSentenceSchema, CreateArticleSentenceParam, UpdateArticleSentenceParam +from backend.app.ai.service.article_service import article_service +from backend.common.response.response_schema import response_base, ResponseSchemaModel +from backend.common.security.jwt import DependsJwtAuth + +router = APIRouter() + + +@router.post("", summary="创建文章", dependencies=[DependsJwtAuth]) +async def create_article( + request: Request, + background_tasks: BackgroundTasks, + params: CreateArticleParam +) -> ResponseSchemaModel[ArticleSchema]: + """ + 创建文章记录 + + 请求体参数: + - title: 文章标题 + - content: 文章完整内容 + - author: 作者(可选) + - category: 分类(可选) + - level: 难度等级(可选) + - info: 附加信息(可选) + + 返回: + - 创建的文章记录 + """ + article_id = await article_service.create_article(obj=params) + article = await article_service.get_article_by_id(article_id) + + return response_base.success(data=article) + + +@router.get("/{article_id}", summary="获取文章详情", dependencies=[DependsJwtAuth]) +async def get_article( + article_id: int +) -> ResponseSchemaModel[ArticleWithParagraphsSchema]: + """ + 获取文章详情,包括所有段落和句子 + + 参数: + - article_id: 文章ID + + 返回: + - 文章记录及其所有段落和句子 + """ + article = await article_service.get_article_with_content(article_id) + if not article: + return response_base.fail(code=404, msg="文章不存在") + + return response_base.success(data=article) + + +@router.put("/{article_id}", summary="更新文章", dependencies=[DependsJwtAuth]) +async def update_article( + article_id: int, + request: Request, + background_tasks: BackgroundTasks, + params: UpdateArticleParam +) -> ResponseSchemaModel[ArticleSchema]: + """ + 更新文章记录 + + 参数: + - article_id: 文章ID + + 请求体参数: + - title: 文章标题 + - content: 文章完整内容 + - author: 作者(可选) + - category: 分类(可选) + - level: 难度等级(可选) + - info: 附加信息(可选) + + 返回: + - 更新后的文章记录 + """ + success = await article_service.update_article(article_id, params) + if not success: + return response_base.fail(code=404, msg="文章不存在") + + article = await article_service.get_article_by_id(article_id) + return response_base.success(data=article) + + +@router.delete("/{article_id}", summary="删除文章", dependencies=[DependsJwtAuth]) +async def delete_article( + article_id: int, + request: Request, + background_tasks: BackgroundTasks +) -> ResponseSchemaModel[None]: + """ + 删除文章记录 + + 参数: + - article_id: 文章ID + + 返回: + - 无 + """ + success = await article_service.delete_article(article_id) + if not success: + return response_base.fail(code=404, msg="文章不存在") + + return response_base.success() + + +@router.post("/paragraph", summary="创建文章段落", dependencies=[DependsJwtAuth]) +async def create_article_paragraph( + request: Request, + background_tasks: BackgroundTasks, + params: CreateArticleParagraphParam +) -> ResponseSchemaModel[ArticleParagraphSchema]: + """ + 创建文章段落记录 + + 请求体参数: + - article_id: 关联的文章ID + - paragraph_index: 段落序号 + - content: 段落内容 + - standard_audio_id: 标准朗读音频文件ID(可选) + - info: 附加信息(可选) + + 返回: + - 创建的段落记录 + """ + paragraph_id = await article_service.create_article_paragraph(obj=params) + paragraph = await article_service.get_article_paragraph_by_id(paragraph_id) + + return response_base.success(data=paragraph) + + +@router.put("/paragraph/{paragraph_id}", summary="更新文章段落", dependencies=[DependsJwtAuth]) +async def update_article_paragraph( + paragraph_id: int, + request: Request, + background_tasks: BackgroundTasks, + params: UpdateArticleParagraphParam +) -> ResponseSchemaModel[ArticleParagraphSchema]: + """ + 更新文章段落记录 + + 参数: + - paragraph_id: 段落ID + + 请求体参数: + - article_id: 关联的文章ID + - paragraph_index: 段落序号 + - content: 段落内容 + - standard_audio_id: 标准朗读音频文件ID(可选) + - info: 附加信息(可选) + + 返回: + - 更新后的段落记录 + """ + success = await article_service.update_article_paragraph(paragraph_id, params) + if not success: + return response_base.fail(code=404, msg="段落不存在") + + paragraph = await article_service.get_article_paragraph_by_id(paragraph_id) + return response_base.success(data=paragraph) + + +@router.delete("/paragraph/{paragraph_id}", summary="删除文章段落", dependencies=[DependsJwtAuth]) +async def delete_article_paragraph( + paragraph_id: int, + request: Request, + background_tasks: BackgroundTasks +) -> ResponseSchemaModel[None]: + """ + 删除文章段落记录 + + 参数: + - paragraph_id: 段落ID + + 返回: + - 无 + """ + success = await article_service.delete_article_paragraph(paragraph_id) + if not success: + return response_base.fail(code=404, msg="段落不存在") + + return response_base.success() + + +@router.get("/paragraph/{paragraph_id}", summary="获取段落详情", dependencies=[DependsJwtAuth]) +async def get_article_paragraph( + paragraph_id: int +) -> ResponseSchemaModel[ArticleParagraphSchema]: + """ + 获取文章段落详情 + + 参数: + - paragraph_id: 段落ID + + 返回: + - 段落记录 + """ + paragraph = await article_service.get_article_paragraph_by_id(paragraph_id) + if not paragraph: + return response_base.fail(code=404, msg="段落不存在") + + return response_base.success(data=paragraph) + + +@router.get("/paragraph", summary="获取文章的所有段落", dependencies=[DependsJwtAuth]) +async def get_article_paragraphs( + article_id: int = Query(..., description="文章ID") +) -> ResponseSchemaModel[List[ArticleParagraphSchema]]: + """ + 获取指定文章的所有段落记录 + + 参数: + - article_id: 文章ID + + 返回: + - 文章的所有段落记录列表 + """ + paragraphs = await article_service.get_article_paragraphs_by_article_id(article_id) + return response_base.success(data=paragraphs) + + +@router.post("/sentence", summary="创建文章句子", dependencies=[DependsJwtAuth]) +async def create_article_sentence( + request: Request, + background_tasks: BackgroundTasks, + params: CreateArticleSentenceParam +) -> ResponseSchemaModel[ArticleSentenceSchema]: + """ + 创建文章句子记录 + + 请求体参数: + - paragraph_id: 关联的段落ID + - sentence_index: 句子序号 + - content: 句子内容 + - standard_audio_id: 标准朗读音频文件ID(可选) + - info: 附加信息(可选) + + 返回: + - 创建的句子记录 + """ + sentence_id = await article_service.create_article_sentence(obj=params) + sentence = await article_service.get_article_sentence_by_id(sentence_id) + + return response_base.success(data=sentence) + + +@router.put("/sentence/{sentence_id}", summary="更新文章句子", dependencies=[DependsJwtAuth]) +async def update_article_sentence( + sentence_id: int, + request: Request, + background_tasks: BackgroundTasks, + params: UpdateArticleSentenceParam +) -> ResponseSchemaModel[ArticleSentenceSchema]: + """ + 更新文章句子记录 + + 参数: + - sentence_id: 句子ID + + 请求体参数: + - paragraph_id: 关联的段落ID + - sentence_index: 句子序号 + - content: 句子内容 + - standard_audio_id: 标准朗读音频文件ID(可选) + - info: 附加信息(可选) + + 返回: + - 更新后的句子记录 + """ + success = await article_service.update_article_sentence(sentence_id, params) + if not success: + return response_base.fail(code=404, msg="句子不存在") + + sentence = await article_service.get_article_sentence_by_id(sentence_id) + return response_base.success(data=sentence) + + +@router.delete("/sentence/{sentence_id}", summary="删除文章句子", dependencies=[DependsJwtAuth]) +async def delete_article_sentence( + sentence_id: int, + request: Request, + background_tasks: BackgroundTasks +) -> ResponseSchemaModel[None]: + """ + 删除文章句子记录 + + 参数: + - sentence_id: 句子ID + + 返回: + - 无 + """ + success = await article_service.delete_article_sentence(sentence_id) + if not success: + return response_base.fail(code=404, msg="句子不存在") + + return response_base.success() + + +@router.get("/sentence/{sentence_id}", summary="获取句子详情", dependencies=[DependsJwtAuth]) +async def get_article_sentence( + sentence_id: int +) -> ResponseSchemaModel[ArticleSentenceSchema]: + """ + 获取文章句子详情 + + 参数: + - sentence_id: 句子ID + + 返回: + - 句子记录 + """ + sentence = await article_service.get_article_sentence_by_id(sentence_id) + if not sentence: + return response_base.fail(code=404, msg="句子不存在") + + return response_base.success(data=sentence) + + +@router.get("/sentence", summary="获取段落的所有句子", dependencies=[DependsJwtAuth]) +async def get_article_sentences( + paragraph_id: int = Query(..., description="段落ID") +) -> ResponseSchemaModel[List[ArticleSentenceSchema]]: + """ + 获取指定段落的所有句子记录 + + 参数: + - paragraph_id: 段落ID + + 返回: + - 段落的所有句子记录列表 + """ + sentences = await article_service.get_article_sentences_by_paragraph_id(paragraph_id) + return response_base.success(data=sentences) \ No newline at end of file diff --git a/backend/app/ai/api/image.py b/backend/app/ai/api/image.py new file mode 100755 index 0000000..b98522d --- /dev/null +++ b/backend/app/ai/api/image.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime + +from fastapi import APIRouter, UploadFile, HTTPException, Request, Response, Query +from fastapi.params import File, Depends +from starlette.background import BackgroundTasks + +from backend.app.admin.schema.audit_log import CreateAuditLogParam +from backend.app.ai.schema.image import ImageRecognizeRes, ProcessImageRequest, ImageBean, ImageShowRes +from backend.app.admin.service.audit_log_service import audit_log_service +from backend.app.ai.service.image_service import ImageService, image_service +from backend.common.response.response_schema import response_base, ResponseSchemaModel +from backend.common.security.jwt import DependsJwtAuth +from backend.app.admin.schema.wx import DictLevel + +router = APIRouter() + + +# @router.post("/upload", summary="上传图片进行识别", dependencies=[DependsJwtAuth]) +# async def upload_image( +# request: Request, background_tasks: BackgroundTasks, file: UploadFile = File(...) +# ) -> ResponseSchemaModel[ImageRecognizeRes]: +# """ +# 上传图片并调用通义千问API进行识别 +# +# 返回: +# - 识别结果 (英文单词或类别) +# - 是否来自缓存 +# - 图片ID +# """ +# image_service.file_verify(file) +# result = await image_service.process_image_upload(file=file, request=request, background_tasks=background_tasks) +# return response_base.success(data=result) + + +@router.post("/recognize", summary="处理已上传的图片文件", dependencies=[DependsJwtAuth]) +async def process_image( + request: Request, + background_tasks: BackgroundTasks, + params: ProcessImageRequest +) -> ResponseSchemaModel[ImageRecognizeRes]: + """ + 处理已上传的图片文件并调用通义千问API进行识别 + + 请求体参数: + - file_id: 已上传文件的ID + - type: 文件类型(默认为"image") + + 返回: + - 识别结果 (英文单词或类别) + - 是否来自缓存 + - 图片ID + """ + result = await image_service.process_image_from_file( + params=params, + request=request, + background_tasks=background_tasks + ) + return response_base.success(data=result) + + +@router.get("/{id}", dependencies=[DependsJwtAuth]) +async def get_image( + request: Request, + id: int, + dict_level: DictLevel = Query(DictLevel.LEVEL1, description="词典等级") +) -> ResponseSchemaModel[ImageShowRes]: + image = await image_service.find_image(id) + + # 检查details和recognition_result是否存在 + if not image.details or "recognition_result" not in image.details: + raise HTTPException(status_code=404, detail="Recognition result not found") + + # 根据dict_level获取对应的识别结果 + recognition_result = image.details["recognition_result"] + if dict_level.value not in recognition_result: + raise HTTPException(status_code=404, detail=f"Recognition result for {dict_level.value} not found") + + result = ImageShowRes( + id=image.id, + file_id=image.file_id, + res=recognition_result[dict_level.value] + ) + return response_base.success(data=result) + +@router.get("/log") +def log(request: Request, background_tasks: BackgroundTasks) -> ResponseSchemaModel[dict]: + audit_log = CreateAuditLogParam( + api_type="test_api", + model_name="test_model", + response_data={"test": "test_response"}, + called_at=datetime.now(), + request_data={"test": "test_request"}, + token_usage={"usage": "test_usage"}, + cost=0.0, + duration=0.0, + status_code=200, + error_message="", + image_id=123123, + user_id=321312, + api_version="test_version", + ) + background_tasks.add_task( + audit_log_service.create, + obj=audit_log, + ) + return response_base.success(data={"succ": True}) + +# @router.get("/download/{image_id}") +# async def download_image(image_id: int): +# result = await image_service.download_image(id=image_id) +# return response_base.success(result) \ No newline at end of file diff --git a/backend/app/ai/api/image_text.py b/backend/app/ai/api/image_text.py new file mode 100644 index 0000000..83dea55 --- /dev/null +++ b/backend/app/ai/api/image_text.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import List +from fastapi import APIRouter, Request, Query +from starlette.background import BackgroundTasks + +from backend.app.ai.schema.image_text import ImageTextSchema, ImageTextWithRecordingsSchema, CreateImageTextParam, UpdateImageTextParam, ImageTextInitResponseSchema, ImageTextInitParam +from backend.app.ai.service.image_text_service import image_text_service +from backend.app.ai.service.recording_service import recording_service +from backend.common.exception import errors +from backend.common.response.response_schema import response_base, ResponseSchemaModel +from backend.common.security.jwt import DependsJwtAuth + +router = APIRouter() + + +@router.post("/init", summary="初始化图片文本", dependencies=[DependsJwtAuth]) +async def init_image_texts( + request: Request, + background_tasks: BackgroundTasks, + params: ImageTextInitParam +) -> ResponseSchemaModel[ImageTextInitResponseSchema]: + """ + 初始化图片文本记录 + 根据dict_level从image的recognition_result中提取文本,如果不存在则创建,如果已存在则直接返回 + + 参数: + - image_id: 图片ID + - dict_level: 词典等级 + + 返回: + - 图片文本记录列表 + """ + result = await image_text_service.init_image_texts(request.user.id, params.image_id, params.dict_level, background_tasks) + return response_base.success(data=result) + + +@router.get("/standard/{text_id}", summary="获取标准音频文件ID", dependencies=[DependsJwtAuth]) +async def get_standard_audio_file_id( + text_id: int, +) -> ResponseSchemaModel[dict]: + """ + 根据文本ID获取标准音频的文件ID,等待异步任务创建完成 + + 参数: + - text_id: 图片文本ID + + 返回: + - 标准音频的文件ID + """ + file_id = await recording_service.get_standard_audio_file_id_by_text_id(text_id) + if not file_id: + raise errors.NotFoundError(msg="标准音频不存在或创建超时") + return response_base.success(data={'audio_id': str(file_id)}) + + +@router.get("/{text_id}", summary="获取图片文本详情", dependencies=[DependsJwtAuth]) +async def get_image_text( + text_id: int +) -> ResponseSchemaModel[ImageTextWithRecordingsSchema]: + """ + 获取图片文本详情,包括关联的录音记录 + + 参数: + - text_id: 图片文本ID + + 返回: + - 图片文本记录及其关联的录音记录 + """ + text = await image_text_service.get_text_by_id(text_id) + if not text: + raise errors.NotFoundError(msg="图片文本不存在") + + # 获取关联的录音记录 + recordings = await recording_service.get_recordings_by_text_id(text_id) + + # 构造返回数据 + result = ImageTextWithRecordingsSchema( + id=text.id, + image_id=text.image_id, + content=text.content, + position=text.position, + dict_level=text.dict_level, + standard_audio_id=text.standard_audio_id, + info=text.info, + created_time=text.created_time, + recordings=recordings + ) + + return response_base.success(data=result) + + +@router.put("/{text_id}", summary="更新图片文本", dependencies=[DependsJwtAuth]) +async def update_image_text( + text_id: int, + request: Request, + background_tasks: BackgroundTasks, + params: UpdateImageTextParam +) -> ResponseSchemaModel[ImageTextSchema]: + """ + 更新图片文本记录 + + 参数: + - text_id: 图片文本ID + + 请求体参数: + - image_id: 图片ID + - content: 文本内容 + - position: 文本在图片中的位置信息(可选) + - dict_level: 词典等级(可选) + - standard_audio_id: 标准朗读音频文件ID(可选) + - info: 附加信息(可选) + + 返回: + - 更新后的图片文本记录 + """ + success = await image_text_service.update_text(text_id, params) + if not success: + return response_base.fail(code=404, msg="图片文本不存在") + + text = await image_text_service.get_text_by_id(text_id) + return response_base.success(data=text) + + +@router.delete("/{text_id}", summary="删除图片文本", dependencies=[DependsJwtAuth]) +async def delete_image_text( + text_id: int, + request: Request, + background_tasks: BackgroundTasks +) -> ResponseSchemaModel[None]: + """ + 删除图片文本记录 + + 参数: + - text_id: 图片文本ID + + 返回: + - 无 + """ + success = await image_text_service.delete_text(text_id) + if not success: + return response_base.fail(code=404, msg="图片文本不存在") + + return response_base.success() + + +@router.get("", summary="获取图片的所有文本", dependencies=[DependsJwtAuth]) +async def get_image_texts( + image_id: int = Query(..., description="图片ID") +) -> ResponseSchemaModel[List[ImageTextSchema]]: + """ + 获取指定图片的所有文本记录 + + 参数: + - image_id: 图片ID + + 返回: + - 图片的所有文本记录列表 + """ + texts = await image_text_service.get_texts_by_image_id(image_id) + return response_base.success(data=texts) \ No newline at end of file diff --git a/backend/app/ai/api/recording.py b/backend/app/ai/api/recording.py new file mode 100644 index 0000000..95d791f --- /dev/null +++ b/backend/app/ai/api/recording.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import List +from fastapi import APIRouter, Request, Query +from starlette.background import BackgroundTasks + +from backend.app.ai.schema.recording import RecordingAssessmentRequest, RecordingAssessmentResponse, ReadingProgressResponse +from backend.app.ai.service.recording_service import recording_service +from backend.app.ai.service.image_text_service import image_text_service +from backend.common.exception import errors +from backend.common.response.response_schema import response_base, ResponseSchemaModel +from backend.common.security.jwt import DependsJwtAuth + +router = APIRouter() + + +@router.post("/assessment", summary="录音评估接口", dependencies=[DependsJwtAuth]) +async def assess_recording( + request: Request, + background_tasks: BackgroundTasks, + params: RecordingAssessmentRequest +) -> ResponseSchemaModel[RecordingAssessmentResponse]: + """ + 录音评估接口 + + 接收文件ID和图片文本ID,调用第三方API获取评估结果并存储到recording表的details字段 + + 请求体参数: + - file_id: 录音文件ID + - image_text_id: 关联的图片文本ID + + 返回: + - file_id: 录音文件ID + - assessment_result: 评估结果 + - image_id: 关联的图片ID + - image_text_id: 关联的图片文本ID + """ + # 获取图片文本记录以获取image_id用于响应 + image_text = await image_text_service.get_text_by_id(params.image_text_id) + if not image_text: + raise errors.NotFoundError(msg=f"ImageText with id {params.image_text_id} not found") + + # 调用录音服务进行评估 + assessment_result = await recording_service.assess_recording( + params.file_id, + # 2087227590107594752, + image_text_id=params.image_text_id, + user_id=request.user.id, + background_tasks=background_tasks + ) + + # 返回结果 + response_data = RecordingAssessmentResponse( + file_id=params.file_id, + assessment_result=assessment_result, + image_text_id=params.image_text_id + ) + + return response_base.success(data=response_data) + + +@router.get("/progress", summary="获取朗读进步统计", dependencies=[DependsJwtAuth]) +async def get_reading_progress( + image_id: int = Query(..., description="图片ID"), + text: str = Query(..., description="朗读文本") +) -> ResponseSchemaModel[ReadingProgressResponse]: + """ + 获取特定图片和文本的朗读进步统计 + + 参数: + - image_id: 图片ID + - text: 朗读文本 + + 返回: + - 包含进步统计信息的字典 + """ + # 获取所有相关的录音记录 + recordings = await recording_service.get_recordings_by_image_and_text(image_id, text) + + # 计算进步统计 + progress_stats = recording_service.calculate_progress(recordings) + + return response_base.success(data=progress_stats) + + +@router.get("/progress-by-text-id", summary="根据文本ID获取朗读进步统计", dependencies=[DependsJwtAuth]) +async def get_reading_progress_by_text_id( + image_text_id: int = Query(..., description="图片文本ID") +) -> ResponseSchemaModel[ReadingProgressResponse]: + """ + 根据图片文本ID获取朗读进步统计 + + 参数: + - image_text_id: 图片文本ID + + 返回: + - 包含进步统计信息的字典 + """ + # 获取所有相关的录音记录 + recordings = await recording_service.get_recordings_by_text_id(image_text_id) + + # 计算进步统计 + progress_stats = recording_service.calculate_progress(recordings) + + return response_base.success(data=progress_stats) \ No newline at end of file diff --git a/backend/app/ai/api/router.py b/backend/app/ai/api/router.py new file mode 100644 index 0000000..8dbfca4 --- /dev/null +++ b/backend/app/ai/api/router.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import APIRouter + +from backend.app.ai.api.image import router as image_router +from backend.app.ai.api.recording import router as recording_router +from backend.app.ai.api.image_text import router as image_text_router +from backend.app.ai.api.article import router as article_router +from backend.core.conf import settings + +v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH) + +v1.include_router(image_router, prefix='/image', tags=['AI图片服务']) +v1.include_router(recording_router, prefix='/recording', tags=['AI录音服务']) +v1.include_router(image_text_router, prefix='/image_text', tags=['AI图片文本服务']) +# v1.include_router(article_router, prefix='/article', tags=['AI文章服务']) \ No newline at end of file diff --git a/backend/app/ai/crud/__init__.py b/backend/app/ai/crud/__init__.py new file mode 100644 index 0000000..853adf8 --- /dev/null +++ b/backend/app/ai/crud/__init__.py @@ -0,0 +1,3 @@ +from backend.app.ai.crud.image_curd import image_dao +from backend.app.ai.crud.image_text_crud import image_text_dao +from backend.app.ai.crud.recording_crud import recording_dao \ No newline at end of file diff --git a/backend/app/ai/crud/article_crud.py b/backend/app/ai/crud/article_crud.py new file mode 100644 index 0000000..9ecb2cd --- /dev/null +++ b/backend/app/ai/crud/article_crud.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Optional, List +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus + +from backend.app.ai.model.article import Article, ArticleParagraph, ArticleSentence + + +class CRUDArticle(CRUDPlus[Article]): + async def get_by_title(self, db: AsyncSession, title: str) -> Optional[Article]: + """根据标题获取文章""" + stmt = select(self.model).where(self.model.title == title) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_articles_by_category(self, db: AsyncSession, category: str) -> List[Article]: + """根据分类获取文章列表""" + stmt = select(self.model).where(self.model.category == category) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +class CRUDArticleParagraph(CRUDPlus[ArticleParagraph]): + async def get_by_article_id(self, db: AsyncSession, article_id: int) -> List[ArticleParagraph]: + """根据文章ID获取所有段落,按序号排序""" + stmt = select(self.model).where(self.model.article_id == article_id).order_by(self.model.paragraph_index) + result = await db.execute(stmt) + return list(result.scalars().all()) + + async def get_by_article_id_and_index(self, db: AsyncSession, article_id: int, paragraph_index: int) -> Optional[ArticleParagraph]: + """根据文章ID和段落序号获取段落""" + stmt = select(self.model).where( + and_( + self.model.article_id == article_id, + self.model.paragraph_index == paragraph_index + ) + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +class CRUDArticleSentence(CRUDPlus[ArticleSentence]): + async def get_by_paragraph_id(self, db: AsyncSession, paragraph_id: int) -> List[ArticleSentence]: + """根据段落ID获取所有句子,按序号排序""" + stmt = select(self.model).where(self.model.paragraph_id == paragraph_id).order_by(self.model.sentence_index) + result = await db.execute(stmt) + return list(result.scalars().all()) + + async def get_by_paragraph_id_and_index(self, db: AsyncSession, paragraph_id: int, sentence_index: int) -> Optional[ArticleSentence]: + """根据段落ID和句子序号获取句子""" + stmt = select(self.model).where( + and_( + self.model.paragraph_id == paragraph_id, + self.model.sentence_index == sentence_index + ) + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +article_dao: CRUDArticle = CRUDArticle(Article) +article_paragraph_dao: CRUDArticleParagraph = CRUDArticleParagraph(ArticleParagraph) +article_sentence_dao: CRUDArticleSentence = CRUDArticleSentence(ArticleSentence) \ No newline at end of file diff --git a/backend/app/ai/crud/image_curd.py b/backend/app/ai/crud/image_curd.py new file mode 100755 index 0000000..1d277b4 --- /dev/null +++ b/backend/app/ai/crud/image_curd.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import List + +import numpy as np +from sqlalchemy import select, update, desc, and_, func, Float +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import Select +from sqlalchemy_crud_plus import CRUDPlus + +from backend.app.ai.model import Image +from backend.app.ai.schema.image import UpdateImageParam, AddImageParam + + +class ImageCRUD(CRUDPlus[Image]): + + async def get(self, db: AsyncSession, id: int) -> Image | None: + return await self.select_model(db, id) + + async def get_by_file_id(self, db: AsyncSession, file_id: int) -> Image | None: + return await self.select_model_by_column(db, file_id=file_id) + + async def get_by_file_id_and_dict_level(self, db: AsyncSession, file_id: int, dict_level: str) -> Image | None: + """ + 根据文件ID和词典等级获取图片记录 + + :param db: 数据库会话 + :param file_id: 文件ID + :param dict_level: 词典等级 + :return: 图片记录或None + """ + return await self.select_model_by_column(db, file_id=file_id, dict_level=dict_level) + + async def get_images_by_file_id(self, db: AsyncSession, file_id: int) -> List[Image]: + """ + 根据文件ID获取具有不同词典等级的图片记录 + + :param db: 数据库会话 + :param file_id: 文件ID + :param dict_level: 当前词典等级 + :return: 图片记录列表 + """ + stmt = select(Image).where(Image.file_id == file_id) + result = await db.execute(stmt) + return result.scalars().all() + + async def get_images_by_file_id_and_different_dict_level(self, db: AsyncSession, file_id: int, dict_level: str) -> \ + List[Image]: + """ + 根据文件ID获取具有不同词典等级的图片记录 + + :param db: 数据库会话 + :param file_id: 文件ID + :param dict_level: 当前词典等级 + :return: 图片记录列表 + """ + stmt = select(Image).where( + and_( + Image.file_id == file_id, + Image.dict_level != dict_level + ) + ) + result = await db.execute(stmt) + return result.scalars().all() + + async def get_images_by_ids(self, db: AsyncSession, image_ids: List[int]) -> List[Image]: + """ + 根据ID列表获取图片记录 + + :param db: 数据库会话 + :param image_ids: 图片ID列表 + :return: 图片记录列表 + """ + if not image_ids: + return [] + stmt = select(Image).where(Image.id.in_(image_ids)) + result = await db.execute(stmt) + return result.scalars().all() + + async def find_similar_images_by_dict_level(self, db: AsyncSession, embedding: List[float], dict_level: str, + top_k: int = 3, threshold: float = 0.8) -> List[int]: + """ + 根据向量和词典等级查找相似图片 + 参数: + db: 数据库会话 + embedding: 1024维向量 + dict_level: 词典等级 + top_k: 返回最相似的K个结果 + threshold: 相似度阈值 (0.0-1.0) + """ + # 确保向量是numpy数组 + if not isinstance(embedding, np.ndarray): + embedding = np.array(embedding) + + # 转换为 NumPy 数组提高性能 + target_embedding = np.array(embedding, dtype=np.float32) + + # 构建查询 + cosine_distance_expr = Image.embedding.cosine_distance(target_embedding) + similarity_expr = (1 - func.cast(cosine_distance_expr, Float)).label("similarity") + # 构建查询 + stmt = select( + Image.id, + # Image.info, + similarity_expr + ).where( + and_( + Image.dict_level == dict_level, + similarity_expr >= threshold + ) + ).order_by( + cosine_distance_expr + ).limit(top_k) + + results = await db.execute(stmt) + id_list: List[int] = results.scalars().all() + return id_list + + async def add(self, db: AsyncSession, new_image: Image) -> None: + db.add(new_image) + + async def update(self, db: AsyncSession, id: int, obj: UpdateImageParam) -> int: + return await self.update_model(db, id, obj) + + async def find_similar_image_ids(self, db: AsyncSession, embedding: List[float], top_k: int = 3, + threshold: float = 0.8) -> List[int]: + """ + 直接通过向量查找相似图片 + 参数: + embedding: 1024维向量 + top_k: 返回最相似的K个结果 + threshold: 相似度阈值 (0.0-1.0) + """ + # 确保向量是numpy数组 + if not isinstance(embedding, np.ndarray): + embedding = np.array(embedding) + + # 转换为 NumPy 数组提高性能 + target_embedding = np.array(embedding, dtype=np.float32) + + # 构建查询 + cosine_distance_expr = Image.embedding.cosine_distance(target_embedding) + similarity_expr = (1 - func.cast(cosine_distance_expr, Float)).label("similarity") + # 构建查询 + stmt = select( + Image.id, + # Image.info, + similarity_expr + ).where( + similarity_expr >= threshold + ).order_by( + cosine_distance_expr + ).limit(top_k) + + results = await db.execute(stmt) + id_list: List[int] = results.scalars().all() + return id_list + + +image_dao: ImageCRUD = ImageCRUD(Image) diff --git a/backend/app/ai/crud/image_text_crud.py b/backend/app/ai/crud/image_text_crud.py new file mode 100644 index 0000000..cce30d1 --- /dev/null +++ b/backend/app/ai/crud/image_text_crud.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Optional, List +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus + +from backend.app.ai.model.image_text import ImageText + + +class ImageTextCRUD(CRUDPlus[ImageText]): + async def get(self, db: AsyncSession, id: int) -> Optional[ImageText]: + """根据ID获取文本记录""" + return await self.select_model(db, id) + + async def get_by_image_id(self, db: AsyncSession, image_id: int) -> List[ImageText]: + """根据图片ID获取所有文本""" + stmt = select(self.model).where(self.model.image_id == image_id) + result = await db.execute(stmt) + return list(result.scalars().all()) + + async def get_by_image_id_and_content(self, db: AsyncSession, image_id: int, content: str) -> Optional[ImageText]: + """根据图片ID和文本内容获取文本记录""" + stmt = select(self.model).where( + and_( + self.model.image_id == image_id, + self.model.content == content + ) + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_standard_audio_id(self, db: AsyncSession, standard_audio_id: int) -> Optional[ImageText]: + """根据标准音频文件ID获取文本记录""" + stmt = select(self.model).where(self.model.standard_audio_id == standard_audio_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def update(self, db: AsyncSession, id: int, obj_in: dict) -> int: + """更新文本记录""" + return await self.update_model(db, id, obj_in) + + +image_text_dao: ImageTextCRUD = ImageTextCRUD(ImageText) \ No newline at end of file diff --git a/backend/app/ai/crud/recording_crud.py b/backend/app/ai/crud/recording_crud.py new file mode 100644 index 0000000..a38dc1e --- /dev/null +++ b/backend/app/ai/crud/recording_crud.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Optional, List +from sqlalchemy import select, and_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy_crud_plus import CRUDPlus + +from backend.app.ai.model.recording import Recording + + +class RecordingCRUD(CRUDPlus[Recording]): + async def get(self, db: AsyncSession, id: int) -> Optional[Recording]: + """根据ID获取录音记录""" + return await self.select_model(db, id) + + async def get_by_file_id(self, db: AsyncSession, file_id: int) -> Optional[Recording]: + """根据文件ID获取录音记录""" + stmt = select(self.model).where(self.model.file_id == file_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_text_id(self, db: AsyncSession, text_id: int) -> List[Recording]: + """根据文本ID获取所有录音记录(不包括标准音频)""" + stmt = select(self.model).where( + and_( + self.model.image_text_id == text_id, + self.model.is_standard == False + ) + ).order_by(self.model.created_time.asc()) + result = await db.execute(stmt) + return list(result.scalars().all()) + + async def get_latest_by_text_id(self, db: AsyncSession, text_id: int) -> Optional[Recording]: + """根据文本ID获取最新的录音记录(不包括标准音频)""" + stmt = select(self.model).where( + and_( + self.model.image_text_id == text_id, + self.model.is_standard == False + ) + ).order_by(self.model.created_time.desc()).limit(1) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + + async def get_standard_by_text_id(self, db: AsyncSession, text_id: int) -> Optional[Recording]: + """根据文本ID获取标准音频记录""" + stmt = select(self.model).where( + and_( + self.model.image_text_id == text_id, + self.model.is_standard == True + ) + ).limit(1) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +recording_dao: RecordingCRUD = RecordingCRUD(Recording) \ No newline at end of file diff --git a/backend/app/ai/model/__init__.py b/backend/app/ai/model/__init__.py new file mode 100644 index 0000000..fc6d943 --- /dev/null +++ b/backend/app/ai/model/__init__.py @@ -0,0 +1,4 @@ +from backend.common.model import MappedBase # noqa: I +from backend.app.ai.model.image import Image +from backend.app.ai.model.image_text import ImageText +# from backend.app.ai.model.article import Article, ArticleParagraph, ArticleSentence \ No newline at end of file diff --git a/backend/app/ai/model/article.py b/backend/app/ai/model/article.py new file mode 100644 index 0000000..361c133 --- /dev/null +++ b/backend/app/ai/model/article.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Optional +from datetime import datetime + +from sqlalchemy import BigInteger, Text, String, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import mapped_column, Mapped + +from backend.common.model import snowflake_id_key, Base + + +class Article(Base): + """ + 文章内容 + 用于存储朗读文章的完整内容,支持多段落结构 + """ + __tablename__ = 'article' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + title: Mapped[str] = mapped_column(String(255), nullable=False, comment="文章标题") + content: Mapped[str] = mapped_column(Text, nullable=False, comment="文章完整内容") + author: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, comment="作者") + category: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="分类") + level: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, comment="难度等级") + info: Mapped[Optional[dict]] = mapped_column(JSONB, default=None, comment="附加信息") + + # 表参数 - 包含所有必要的约束 + __table_args__ = ( + ) + + +class ArticleParagraph(Base): + """ + 文章段落 + 用于存储文章中的各个段落,支持段落级别的朗读和评估 + """ + __tablename__ = 'article_paragraph' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + article_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('article.id'), nullable=False, comment="关联的文章ID") + paragraph_index: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="段落序号") + content: Mapped[str] = mapped_column(Text, nullable=False, comment="段落内容") + standard_audio_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('file.id'), nullable=True, comment="标准朗读音频文件ID") + info: Mapped[Optional[dict]] = mapped_column(JSONB, default=None, comment="附加信息") + + # 表参数 - 包含所有必要的约束 + __table_args__ = ( + ) + + +class ArticleSentence(Base): + """ + 文章句子 + 用于存储段落中的各个句子,支持句子级别的朗读和评估 + """ + __tablename__ = 'article_sentence' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + paragraph_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('article_paragraph.id'), nullable=False, comment="关联的段落ID") + sentence_index: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="句子序号") + content: Mapped[str] = mapped_column(Text, nullable=False, comment="句子内容") + standard_audio_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('file.id'), nullable=True, comment="标准朗读音频文件ID") + info: Mapped[Optional[dict]] = mapped_column(JSONB, default=None, comment="附加信息") + + # 表参数 - 包含所有必要的约束 + __table_args__ = ( + ) \ No newline at end of file diff --git a/backend/app/ai/model/image.py b/backend/app/ai/model/image.py new file mode 100755 index 0000000..0f4ed71 --- /dev/null +++ b/backend/app/ai/model/image.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Optional + +from sqlalchemy import BigInteger, Text, String, Index, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB +from pgvector.sqlalchemy import Vector +from sqlalchemy.orm import mapped_column, Mapped + +from backend.app.ai.schema.image import ImageMetadata +from backend.app.admin.schema.pydantic_type import PydanticType +from backend.common.model import snowflake_id_key, Base + + +class Image(Base): + __tablename__ = 'image' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + file_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('file.id'), nullable=True, comment="关联的文件ID") + thumbnail_id: Mapped[Optional[int]] = mapped_column(BigInteger, default=None, nullable=True, comment="缩略图ID") + embedding: Mapped[Optional[list[float]]] = mapped_column(Vector(1024), default=None, nullable=True) # 1024 维向量 + info: Mapped[Optional[ImageMetadata]] = mapped_column(PydanticType(pydantic_type=ImageMetadata), default=None, comment="附加元数据") # 其他可能的字段(根据实际需求添加) + details: Mapped[Optional[dict]] = mapped_column(JSONB(astext_type=Text()), default=None, comment="其他信息") # 其他信息 + + # 表参数 - 包含所有必要的约束 + __table_args__ = ( + # 为 embedding 字段添加 HNSW 索引 (pgvector) + Index( + 'idx_image_embedding_hnsw', + embedding, + postgresql_using='hnsw', + postgresql_with={'m': 16, 'ef_construction': 64}, + postgresql_ops={'embedding': 'vector_l2_ops'} # 修复在此 + ), + # 为 thumbnail_id 添加索引以优化查询 + Index('idx_image_thumbnail_id', 'thumbnail_id'), + ) + + def file_extension(self): + # 安全访问嵌套属性 + if self.info and hasattr(self.info, 'format'): + return self.info.format.value.lower() + return "jpg" \ No newline at end of file diff --git a/backend/app/ai/model/image_text.py b/backend/app/ai/model/image_text.py new file mode 100644 index 0000000..a13b608 --- /dev/null +++ b/backend/app/ai/model/image_text.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Optional + +from sqlalchemy import BigInteger, Text, String, Integer, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import mapped_column, Mapped + +from backend.common.model import snowflake_id_key, Base + + +class ImageText(Base): + """ + 图片识别出的文本内容 + 每张图片可能包含多个文本,这些文本是图片识别的结果 + 支持关联文章句子,image_id 为可选以支持独立的文章句子文本 + """ + __tablename__ = 'image_text' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + image_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('image.id'), nullable=True, comment="关联的图片ID") + article_sentence_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('article_sentence.id'), nullable=True, comment="关联的文章句子ID") + content: Mapped[str] = mapped_column(Text, nullable=False, comment="文本内容") + standard_audio_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('file.id'), nullable=True, comment="标准朗读音频文件ID") + ipa: Mapped[Optional[str]] = mapped_column(String(100), default=None, comment="ipa") + position: Mapped[Optional[dict]] = mapped_column(JSONB, default=None, comment="文本在图片中的位置信息或文章中的位置信息") + dict_level: Mapped[Optional[str]] = mapped_column(String(20), default=None, comment="词典等级") + source: Mapped[Optional[str]] = mapped_column(String(20), default=None, comment="文本来源 (ref_word/description/article)") + info: Mapped[Optional[dict]] = mapped_column(JSONB, default=None, comment="附加信息") + + # 表参数 - 包含所有必要的约束 + __table_args__ = ( + ) \ No newline at end of file diff --git a/backend/app/ai/model/recording.py b/backend/app/ai/model/recording.py new file mode 100755 index 0000000..c133249 --- /dev/null +++ b/backend/app/ai/model/recording.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Optional +from datetime import datetime + +from sqlalchemy import BigInteger, Text, ForeignKey, String, Integer, DateTime, Boolean +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import mapped_column, Mapped + +from backend.app.ai.schema.recording import RecordingMetadata +from backend.app.admin.schema.pydantic_type import PydanticType +from backend.common.model import snowflake_id_key, Base + + +class Recording(Base): + __tablename__ = 'recording' + + id: Mapped[snowflake_id_key] = mapped_column(BigInteger, init=False, primary_key=True) + file_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('file.id'), nullable=True, comment="关联的文件ID") + user_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('wx_user.id'), nullable=True, comment="关联的用户ID") + image_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('image.id'), nullable=True, comment="关联的图片ID") + image_text_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('image_text.id'), nullable=True, comment="关联的图片文本ID") + article_sentence_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey('article_sentence.id'), nullable=True, comment="关联的文章句子ID") + text: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment='朗读文本') + eval_mode: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, comment='评测模式') + info: Mapped[Optional[RecordingMetadata]] = mapped_column(PydanticType(pydantic_type=RecordingMetadata), default=None, comment="附加元数据") # 其他可能的字段(根据实际需求添加) + details: Mapped[Optional[dict]] = mapped_column(JSONB(astext_type=Text()), default=None, comment="评估信息") # 其他信息 + is_standard: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否为标准朗读音频") + + # 表参数 - 包含所有必要的约束 + __table_args__ = ( + ) \ No newline at end of file diff --git a/backend/app/ai/schema/__init__.py b/backend/app/ai/schema/__init__.py new file mode 100644 index 0000000..c3fc081 --- /dev/null +++ b/backend/app/ai/schema/__init__.py @@ -0,0 +1 @@ +from backend.app.ai.schema.image import ImageMetadata, ImageFormat, ColorMode, ImageBean, ImageRecognizeRes, ImageInfoSchemaBase, AddImageParam, UpdateImageParam, ProcessImageRequest \ No newline at end of file diff --git a/backend/app/ai/schema/article.py b/backend/app/ai/schema/article.py new file mode 100644 index 0000000..3bc8606 --- /dev/null +++ b/backend/app/ai/schema/article.py @@ -0,0 +1,90 @@ +from typing import Optional, Dict, Any, List +from pydantic import BaseModel + +from backend.common.schema import SchemaBase + + +class ArticleSchemaBase(SchemaBase): + """文章基础结构""" + title: str + content: str + author: Optional[str] = None + category: Optional[str] = None + level: Optional[str] = None + info: Optional[dict] = None + + +class CreateArticleParam(ArticleSchemaBase): + """创建文章参数""" + + +class UpdateArticleParam(ArticleSchemaBase): + """更新文章参数""" + + +class ArticleSchema(ArticleSchemaBase): + """文章信息""" + id: int + created_time: Optional[str] = None + updated_time: Optional[str] = None + + +class ArticleParagraphSchemaBase(SchemaBase): + """文章段落基础结构""" + article_id: int + paragraph_index: int + content: str + standard_audio_id: Optional[int] = None + info: Optional[dict] = None + + +class CreateArticleParagraphParam(ArticleParagraphSchemaBase): + """创建文章段落参数""" + + +class UpdateArticleParagraphParam(ArticleParagraphSchemaBase): + """更新文章段落参数""" + + +class ArticleParagraphSchema(ArticleParagraphSchemaBase): + """文章段落信息""" + id: int + created_time: Optional[str] = None + + +class ArticleSentenceSchemaBase(SchemaBase): + """文章句子基础结构""" + paragraph_id: int + sentence_index: int + content: str + standard_audio_id: Optional[int] = None + info: Optional[dict] = None + + +class CreateArticleSentenceParam(ArticleSentenceSchemaBase): + """创建文章句子参数""" + + +class UpdateArticleSentenceParam(ArticleSentenceSchemaBase): + """更新文章句子参数""" + + +class ArticleSentenceSchema(ArticleSentenceSchemaBase): + """文章句子信息""" + id: int + created_time: Optional[str] = None + + +class ArticleWithParagraphsSchema(ArticleSchema): + """包含段落的文章信息""" + paragraphs: Optional[List['ArticleParagraphWithSentencesSchema']] = None + + +class ArticleParagraphWithSentencesSchema(ArticleParagraphSchema): + """包含句子的段落信息""" + sentences: Optional[List[ArticleSentenceSchema]] = None + + +# Update forward references +ArticleWithParagraphsSchema.model_rebuild() +ArticleParagraphWithSentencesSchema.model_rebuild() \ No newline at end of file diff --git a/backend/app/ai/schema/image.py b/backend/app/ai/schema/image.py new file mode 100755 index 0000000..7311bbd --- /dev/null +++ b/backend/app/ai/schema/image.py @@ -0,0 +1,93 @@ +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any +from enum import Enum + +from sqlalchemy.sql.sqltypes import BigInteger + +from backend.common.schema import SchemaBase +from backend.app.admin.schema.wx import DictLevel + + +class ImageFormat(str, Enum): + JPEG = "jpeg" + PNG = "png" + GIF = "gif" + BMP = "bmp" + WEBP = "webp" + TIFF = "tiff" + SVG = "svg" + UNKNOWN = "unknown" + + +class ColorMode(str, Enum): + RGB = "RGB" + RGBA = "RGBA" + L = "L" # 8-bit grayscale + LA = "LA" # Grayscale with alpha + CMYK = "CMYK" + LAB = "LAB" + HSV = "HSV" + UNKNOWN = "unknown" + + +class ImageMetadata(BaseModel): + """图片元数据结构""" + file_name: Optional[str] = None + content_type: Optional[str] = None + format: ImageFormat + width: int + height: int + color_mode: ColorMode + file_size: int # 文件大小(字节) + channels: Optional[int] = None # 颜色通道数 + dpi: Optional[tuple] = None # (x_dpi, y_dpi) + exif: Optional[Dict[str, Any]] = None # EXIF元数据 + dominant_colors: Optional[list] = None # 主色调(RGB值) + created_at: Optional[str] = None # 创建时间(如果有) + source: Optional[str] = None # 图片来源 + user_agent: Optional[str] = None # 上传客户端信息 + error: Optional[str] = Field(default="", description="提取过程中的错误信息") + + def dict(self, **kwargs): + # 确保所有枚举值转换为字符串 + data = super().dict(**kwargs) + + # 处理特殊类型 + if data.get("dpi"): + data["dpi"] = list(data["dpi"]) + + # 清理None值 + return {k: v for k, v in data.items() if v is not None} + + +class ImageBean(SchemaBase): + id: int + + +class ImageRecognizeRes(ImageBean): + res: dict | list | None + + +class ImageShowRes(ImageRecognizeRes): + file_id: int + + +class ImageInfoSchemaBase(SchemaBase): + embedding: Optional[list] = None + info: Optional[ImageMetadata] = None + details: Optional[dict] = None + + +class AddImageParam(ImageInfoSchemaBase): + """ Image Update Parameters """ + + +class UpdateImageParam(ImageInfoSchemaBase): + """ Image Update Parameters """ + thumbnail_id: Optional[int] = None + + +class ProcessImageRequest(BaseModel): + file_id: int + type: str = "word" + dict_level: Optional[DictLevel] = Field(None, description="词典等级") \ No newline at end of file diff --git a/backend/app/ai/schema/image_text.py b/backend/app/ai/schema/image_text.py new file mode 100644 index 0000000..022ddb9 --- /dev/null +++ b/backend/app/ai/schema/image_text.py @@ -0,0 +1,53 @@ +from typing import Optional, Dict, Any, List +from pydantic import BaseModel + +from backend.common.schema import SchemaBase + + +class ImageTextSchemaBase(SchemaBase): + """图片文本基础结构""" + image_id: int + content: str + position: Optional[Dict[str, Any]] = None + dict_level: Optional[str] = None + standard_audio_id: Optional[int] = None + info: Optional[dict] = None + + +class CreateImageTextParam(ImageTextSchemaBase): + """创建图片文本参数""" + + +class UpdateImageTextParam(ImageTextSchemaBase): + """更新图片文本参数""" + + +class ImageTextSchema(ImageTextSchemaBase): + """图片文本信息""" + id: int + created_time: Optional[str] = None + + +class ImageTextWithRecordingsSchema(ImageTextSchema): + """包含录音信息的图片文本信息""" + recordings: Optional[list] = None + + +class ImageTextAssessmentSchema(BaseModel): + """图片文本评估信息结构""" + id: str + content: str + ipa: str + details: Optional[dict] = None + + +class ImageTextInitParam(BaseModel): + """初始化图片文本参数""" + image_id: int + dict_level: str + + +class ImageTextInitResponseSchema(BaseModel): + """初始化图片文本响应结构""" + image_file_id: Optional[str] = None + assessments: List[ImageTextAssessmentSchema] = [] \ No newline at end of file diff --git a/backend/app/ai/schema/recording.py b/backend/app/ai/schema/recording.py new file mode 100644 index 0000000..5a05acf --- /dev/null +++ b/backend/app/ai/schema/recording.py @@ -0,0 +1,88 @@ +from typing import Optional, Dict, Any, List +from pydantic import BaseModel, Field +from enum import Enum +from datetime import datetime + +from backend.common.schema import SchemaBase + + +class RecordingFormat(str, Enum): + WAV = "wav" + MP3 = "mp3" + FLAC = "flac" + AAC = "aac" + OGG = "ogg" + M4A = "m4a" + WMA = "wma" + UNKNOWN = "unknown" + + +class RecordingMetadata(BaseModel): + """录音元数据结构""" + file_name: Optional[str] = None + content_type: Optional[str] = None + format: RecordingFormat = RecordingFormat.UNKNOWN + duration: Optional[float] = None # 录音时长(秒) + sample_rate: Optional[int] = None # 采样率(Hz) + bit_rate: Optional[int] = None # 比特率(kbps) + channels: Optional[int] = None # 声道数 + file_size: Optional[int] = None # 文件大小(字节) + codec: Optional[str] = None # 编码格式 + created_at: Optional[str] = None # 创建时间 + source: Optional[str] = None # 录音来源 + user_agent: Optional[str] = None # 上传客户端信息 + error: Optional[str] = Field(default="", description="提取过程中的错误信息") + + def dict(self, **kwargs): + # 确保所有枚举值转换为字符串 + data = super().dict(**kwargs) + # 清理None值 + return {k: v for k, v in data.items() if v is not None} + + +class RecordingAssessmentWord(BaseModel): + word: str + score: float + start_time: float + end_time: float + + +class RecordingAssessmentFeedback(BaseModel): + pronunciation: str + fluency: str + rhythm: str + + +class RecordingAssessmentResult(BaseModel): + assessment: Optional[dict] = None + # words: List[RecordingAssessmentWord] + + +class RecordingAssessmentRequest(SchemaBase): + file_id: int + image_text_id: int + + +class RecordingAssessmentResponse(SchemaBase): + file_id: Optional[int] + assessment_result: RecordingAssessmentResult + image_text_id: Optional[int] = None + + +class RecordingScore(BaseModel): + timestamp: datetime + overall_score: float + pronunciation_score: float + fluency_score: float + integrity_score: float + + +class ReadingProgressResponse(BaseModel): + total_attempts: int + first_score: float + latest_score: float + total_progress: float + average_progress_per_attempt: float + max_score: RecordingScore + min_score: RecordingScore + all_scores: List[RecordingScore] \ No newline at end of file diff --git a/backend/app/ai/service/__init__.py b/backend/app/ai/service/__init__.py new file mode 100644 index 0000000..8420458 --- /dev/null +++ b/backend/app/ai/service/__init__.py @@ -0,0 +1,2 @@ +from backend.app.ai.service.image_service import ImageService, image_service +from backend.app.ai.service.image_text_service import ImageTextService, image_text_service \ No newline at end of file diff --git a/backend/app/ai/service/article_service.py b/backend/app/ai/service/article_service.py new file mode 100644 index 0000000..8180ea9 --- /dev/null +++ b/backend/app/ai/service/article_service.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import logging +from typing import Optional, List + +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.ai.crud.article_crud import article_dao, article_paragraph_dao, article_sentence_dao +from backend.app.ai.model.article import Article, ArticleParagraph, ArticleSentence +from backend.app.ai.schema.article import CreateArticleParam, UpdateArticleParam, CreateArticleParagraphParam, UpdateArticleParagraphParam, CreateArticleSentenceParam, UpdateArticleSentenceParam +from backend.database.db import async_db_session + +logger = logging.getLogger(__name__) + + +class ArticleService: + @staticmethod + async def create_article(*, obj: CreateArticleParam) -> int: + """ + 创建文章 + + :param obj: 文章创建参数 + :return: 文章ID + """ + async with async_db_session.begin() as db: + article = await article_dao.create(db, obj) + return article.id + + @staticmethod + async def get_article_by_id(article_id: int) -> Optional[Article]: + """ + 根据ID获取文章 + + :param article_id: 文章ID + :return: 文章记录 + """ + async with async_db_session() as db: + return await article_dao.get(db, article_id) + + @staticmethod + async def get_article_with_content(article_id: int) -> Optional[dict]: + """ + 获取文章及其所有段落和句子 + + :param article_id: 文章ID + :return: 包含段落和句子的文章信息 + """ + async with async_db_session() as db: + article = await article_dao.get(db, article_id) + if not article: + return None + + # 获取所有段落 + paragraphs = await article_paragraph_dao.get_by_article_id(db, article_id) + + # 获取所有句子 + paragraph_ids = [p.id for p in paragraphs] + if paragraph_ids: + stmt = select(ArticleSentence).where(ArticleSentence.paragraph_id.in_(paragraph_ids)).order_by(ArticleSentence.paragraph_id, ArticleSentence.sentence_index) + result = await db.execute(stmt) + sentences = list(result.scalars().all()) + + # 将句子按段落分组 + sentences_by_paragraph = {} + for sentence in sentences: + if sentence.paragraph_id not in sentences_by_paragraph: + sentences_by_paragraph[sentence.paragraph_id] = [] + sentences_by_paragraph[sentence.paragraph_id].append(sentence) + + # 将句子添加到对应的段落中 + for paragraph in paragraphs: + paragraph.sentences = sentences_by_paragraph.get(paragraph.id, []) + + article.paragraphs = paragraphs + return article + + @staticmethod + async def update_article(article_id: int, obj: UpdateArticleParam) -> bool: + """ + 更新文章 + + :param article_id: 文章ID + :param obj: 文章更新参数 + :return: 是否更新成功 + """ + async with async_db_session.begin() as db: + return await article_dao.update(db, article_id, obj) + + @staticmethod + async def delete_article(article_id: int) -> bool: + """ + 删除文章 + + :param article_id: 文章ID + :return: 是否删除成功 + """ + async with async_db_session.begin() as db: + return await article_dao.delete(db, article_id) + + @staticmethod + async def create_article_paragraph(*, obj: CreateArticleParagraphParam) -> int: + """ + 创建文章段落 + + :param obj: 段落创建参数 + :return: 段落ID + """ + async with async_db_session.begin() as db: + paragraph = await article_paragraph_dao.create(db, obj) + return paragraph.id + + @staticmethod + async def get_article_paragraph_by_id(paragraph_id: int) -> Optional[ArticleParagraph]: + """ + 根据ID获取文章段落 + + :param paragraph_id: 段落ID + :return: 段落记录 + """ + async with async_db_session() as db: + return await article_paragraph_dao.get(db, paragraph_id) + + @staticmethod + async def get_article_paragraphs_by_article_id(article_id: int) -> List[ArticleParagraph]: + """ + 根据文章ID获取所有段落 + + :param article_id: 文章ID + :return: 段落记录列表 + """ + async with async_db_session() as db: + return await article_paragraph_dao.get_by_article_id(db, article_id) + + @staticmethod + async def update_article_paragraph(paragraph_id: int, obj: UpdateArticleParagraphParam) -> bool: + """ + 更新文章段落 + + :param paragraph_id: 段落ID + :param obj: 段落更新参数 + :return: 是否更新成功 + """ + async with async_db_session.begin() as db: + return await article_paragraph_dao.update(db, paragraph_id, obj) + + @staticmethod + async def delete_article_paragraph(paragraph_id: int) -> bool: + """ + 删除文章段落 + + :param paragraph_id: 段落ID + :return: 是否删除成功 + """ + async with async_db_session.begin() as db: + return await article_paragraph_dao.delete(db, paragraph_id) + + @staticmethod + async def create_article_sentence(*, obj: CreateArticleSentenceParam) -> int: + """ + 创建文章句子 + + :param obj: 句子创建参数 + :return: 句子ID + """ + async with async_db_session.begin() as db: + sentence = await article_sentence_dao.create(db, obj) + return sentence.id + + @staticmethod + async def get_article_sentence_by_id(sentence_id: int) -> Optional[ArticleSentence]: + """ + 根据ID获取文章句子 + + :param sentence_id: 句子ID + :return: 句子记录 + """ + async with async_db_session() as db: + return await article_sentence_dao.get(db, sentence_id) + + @staticmethod + async def get_article_sentences_by_paragraph_id(paragraph_id: int) -> List[ArticleSentence]: + """ + 根据段落ID获取所有句子 + + :param paragraph_id: 段落ID + :return: 句子记录列表 + """ + async with async_db_session() as db: + return await article_sentence_dao.get_by_paragraph_id(db, paragraph_id) + + @staticmethod + async def update_article_sentence(sentence_id: int, obj: UpdateArticleSentenceParam) -> bool: + """ + 更新文章句子 + + :param sentence_id: 句子ID + :param obj: 句子更新参数 + :return: 是否更新成功 + """ + async with async_db_session.begin() as db: + return await article_sentence_dao.update(db, sentence_id, obj) + + @staticmethod + async def delete_article_sentence(sentence_id: int) -> bool: + """ + 删除文章句子 + + :param sentence_id: 句子ID + :return: 是否删除成功 + """ + async with async_db_session.begin() as db: + return await article_sentence_dao.delete(db, sentence_id) + + +article_service = ArticleService() \ No newline at end of file diff --git a/backend/app/ai/service/image_service.py b/backend/app/ai/service/image_service.py new file mode 100755 index 0000000..520c651 --- /dev/null +++ b/backend/app/ai/service/image_service.py @@ -0,0 +1,527 @@ +import base64 +import hashlib +import imghdr +import io +import json +from datetime import datetime + +import numpy as np +from PIL import Image as PILImage, ExifTags +from fastapi import UploadFile, Request +from starlette.background import BackgroundTasks + +from backend.app.ai.crud.image_curd import image_dao +from backend.app.admin.crud.dict_crud import dict_dao +from backend.app.ai.model import Image +from backend.app.ai.schema.image import ImageFormat, ImageMetadata, ColorMode, ImageRecognizeRes, UpdateImageParam, \ + ProcessImageRequest +from backend.app.admin.schema.qwen import QwenEmbedImageParams, QwenRecognizeImageParams +from backend.app.admin.service.dict_service import dict_service +from backend.app.admin.service.file_service import file_service +from backend.common.enums import FileType +from backend.common.exception import errors +from backend.core.conf import settings +from backend.common.log import log as logger +from typing import Optional, Dict, Any, List + +from backend.database.db import async_db_session +from backend.middleware.qwen import Qwen + + +class ImageService: + + @staticmethod + def file_verify(file: UploadFile) -> None: + """ + 文件验证 + + :param file: FastAPI 上传文件对象 + :return: + """ + filename = file.filename + file_ext = filename.split('.')[-1].lower() + if not file_ext: + raise errors.ForbiddenError(msg='未知的文件类型') + + if file_ext == FileType.image: + if file_ext not in settings.UPLOAD_IMAGE_EXT_INCLUDE: + raise errors.ForbiddenError(msg=f'[{file_ext}] 此图片格式暂不支持') + if file.size > settings.UPLOAD_IMAGE_SIZE_MAX: + raise errors.ForbiddenError(msg=f'图片超出最大限制,请重新选择') + + @staticmethod + async def load_image_by_id(self, id: int) -> Image: + async with async_db_session() as db: + image = await image_dao.get(db, id) + if not image: + errors.NotFoundError(msg='图片不存在') + return image + + @staticmethod + def detect_image_format(image_bytes: bytes) -> ImageFormat: + """通过二进制数据检测图片格式""" + # 使用imghdr识别基础格式 + format_str = imghdr.what(None, h=image_bytes) + + # 映射到枚举类型 + format_mapping = { + 'jpeg': ImageFormat.JPEG, + 'jpg': ImageFormat.JPEG, + 'png': ImageFormat.PNG, + 'gif': ImageFormat.GIF, + 'bmp': ImageFormat.BMP, + 'webp': ImageFormat.WEBP, + 'tiff': ImageFormat.TIFF, + 'svg': ImageFormat.SVG + } + + return format_mapping.get(format_str, ImageFormat.UNKNOWN) + + @staticmethod + def extract_metadata(image_bytes: bytes, additional_info: Dict[str, Any] = None) -> ImageMetadata: + """从图片二进制数据中提取元数据""" + try: + with PILImage.open(io.BytesIO(image_bytes)) as img: + # 获取基础信息 + width, height = img.size + color_mode = ColorMode(img.mode) if img.mode in ColorMode.__members__.values() else ColorMode.UNKNOWN + + # 获取EXIF数据 + exif_data = {} + if hasattr(img, '_getexif') and img._getexif(): + for tag, value in img._getexif().items(): + decoded_tag = ExifTags.TAGS.get(tag, tag) + # 特殊处理日期时间 + if decoded_tag in ['DateTime', 'DateTimeOriginal', 'DateTimeDigitized']: + try: + value = datetime.strptime(value, "%Y:%m:%d %H:%M:%S").isoformat() + except: + pass + exif_data[decoded_tag] = value + + # 获取颜色通道数 + channels = len(img.getbands()) + + # 尝试获取DPI + dpi = img.info.get('dpi') + + # 创建元数据对象 + metadata = ImageMetadata( + format=ImageService.detect_image_format(image_bytes), + width=width, + height=height, + color_mode=color_mode, + file_size=len(image_bytes), + channels=channels, + dpi=dpi, + exif=exif_data + ) + + # 添加额外信息 + if additional_info: + for key, value in additional_info.items(): + if hasattr(metadata, key): + setattr(metadata, key, value) + + return metadata + except Exception as e: + # 无法解析图片时返回基础元数据 + return ImageMetadata( + format=ImageService.detect_image_format(image_bytes), + width=0, + height=0, + color_mode=ColorMode.UNKNOWN, + file_size=len(image_bytes), + error=f"Metadata extraction failed: {str(e)}" + ) + + @staticmethod + def calculate_image_hash(image_bytes: bytes) -> str: + """计算图片的SHA256哈希值""" + return hashlib.sha256(image_bytes).hexdigest() + + @staticmethod + def _local_embedding(image_bytes: bytes) -> np.ndarray: + """本地嵌入生成回退方法""" + try: + # 使用轻量级模型生成嵌入 (示例使用伪代码) + img = PILImage.open(io.BytesIO(image_bytes)) + img = img.resize((224, 224)) + img_array = np.array(img) / 255.0 + + # 伪代码 - 实际应使用预训练模型 + embedding = np.random.rand(1024) + embedding = embedding / np.linalg.norm(embedding) + return embedding + except Exception as e: + logger.error(f"Local embedding failed: {str(e)}") + return np.zeros(1024) # 返回零向量 + + @staticmethod + async def generate_thumbnail(image_id: int, file_id: int) -> None: + """生成缩略图并更新image记录""" + try: + # 下载原始图片 + file_content, file_name, content_type = await file_service.download_file(file_id) + + # 生成缩略图 + thumbnail_content = await ImageService._create_thumbnail(file_content) + + # 如果缩略图生成失败,使用原始图片作为缩略图 + if not thumbnail_content: + thumbnail_content = file_content + + # 上传缩略图到文件服务 + # 创建一个虚拟的文件对象用于上传 + class MockUploadFile: + def __init__(self, filename, content): + self.filename = filename + self._file = io.BytesIO(content) + self.size = len(content) + + async def read(self): + """读取文件内容""" + self._file.seek(0) + return self._file.read() + + async def seek(self, position): + """重置文件指针""" + self._file.seek(position) + + thumbnail_file = MockUploadFile( + filename=f"thumbnail_{file_name}", + content=thumbnail_content + ) + + # 上传缩略图,使用新的方法并显式传递content_type + thumbnail_response = await file_service.upload_file_with_content_type( + thumbnail_file, + content_type=content_type + ) + thumbnail_file_id = int(thumbnail_response.id) + + # 更新image记录的thumbnail_id字段 + async with async_db_session.begin() as db: + await image_dao.update( + db, + image_id, + UpdateImageParam(thumbnail_id=thumbnail_file_id) + ) + + except Exception as e: + logger.error(f"生成缩略图失败: {str(e)}") + # 不抛出异常,避免影响主流程 + + @staticmethod + async def _create_thumbnail(image_bytes: bytes, size: tuple = (100, 100)) -> bytes: + """创建缩略图""" + try: + # 检查输入是否为空 + if not image_bytes: + return None + + # 打开原始图片 + with PILImage.open(io.BytesIO(image_bytes)) as img: + # 转换为RGB模式(如果需要) + if img.mode in ("RGBA", "LA", "P"): + # 创建白色背景 + background = PILImage.new("RGB", img.size, (255, 255, 255)) + if img.mode == "P": + img = img.convert("RGBA") + background.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None) + img = background + + # 居中裁剪图片为正方形 + width, height = img.size + if width > height: + # 宽度大于高度,裁剪水平中部 + left = (width - height) // 2 + right = left + height + top = 0 + bottom = height + else: + # 高度大于宽度,裁剪垂直中部 + left = 0 + right = width + top = (height - width) // 2 + bottom = top + width + + # 执行裁剪 + img = img.crop((left, top, right, bottom)) + + # 调整图片尺寸为指定大小 + img = img.resize(size, PILImage.Resampling.LANCZOS) + + # 保存缩略图到字节流 + thumbnail_buffer = io.BytesIO() + img.save(thumbnail_buffer, format=img.format or "JPEG") + thumbnail_buffer.seek(0) + + return thumbnail_buffer.read() + except Exception as e: + logger.error(f"创建缩略图失败: {str(e)}") + # 如果失败,返回None + return None + + @staticmethod + async def process_image_from_file( + params: ProcessImageRequest, + background_tasks: BackgroundTasks, + request: Request + ) -> ImageRecognizeRes: + """通过文件ID处理图片识别""" + + current_user = request.user + file_id = params.file_id + type = params.type + dict_level = params.dict_level.name + if not dict_level: + dict_level = current_user.dict_level.name + + # 通过file_id读取文件内容 + try: + file_content, file_name, content_type = await file_service.download_file(file_id) + except Exception as e: + raise errors.NotFoundError(msg=f"文件不存在或无法读取: {str(e)}") + + # 提前提取图片格式 + image_format = image_service.detect_image_format(file_content) + image_format_str = image_format.value + + base64_image = base64.b64encode(file_content).decode('utf-8') + + async with async_db_session.begin() as db: + # 获取exclude_words + exclude_words = [] + + # 检查是否在image表中已有记录(根据file_id和dict_level) + existing_image = await image_dao.get_by_file_id(db, file_id) + if existing_image: + recognition_result = existing_image.details["recognition_result"] + if recognition_result and dict_level in recognition_result: + exist_result = recognition_result.get(dict_level) + return image_service.wrap_exist_image_with_result(existing_image, exist_result) + else: + exclude_words.extend([ + word for section in recognition_result.values() + for word in section.get('ref_word', []) + if isinstance(section.get('ref_word'), list) + ]) + image_id = existing_image.id + else: + # insert image + new_image = Image( + file_id=file_id, + ) + + await image_dao.add(db, new_image) + await db.flush() # 获取ID + image_id = new_image.id + + # 生成缩略图 + background_tasks.add_task(ImageService.generate_thumbnail, image_id, file_id) + # await image_service.generate_thumbnail(image_id, file_id) + + # embedding + embed_params = QwenEmbedImageParams( + user_id=current_user.id, + dict_level=dict_level, + image_id=new_image.id, + file_name=file_name, + format=image_format_str, + data=base64_image, + ) + embed_response = Qwen.embed_image(embed_params, background_tasks) + if embed_response.get("error"): + raise Exception(embed_response["error"]) + + embedding = embed_response.get("embedding") + + # 提取元数据 + additional_info = { + "file_name": file_name, + "content_type": content_type, + "file_size": len(file_content), + } + metadata = file_service.extract_image_metadata(file_content, additional_info) + + # 相似图片 + similar_image_ids = await image_dao.find_similar_image_ids(db, embedding) + + # 2. 获取相似图片中不同dict_level的图片,从中提取ref_word + if similar_image_ids: + similar_image = await image_dao.get(db, similar_image_ids[0]) + if similar_image: + recognition_result = similar_image.details["recognition_result"] + if recognition_result and dict_level in recognition_result: + exist_result = recognition_result.get(dict_level) + res = image_service.wrap_exist_image_with_result(similar_image, exist_result) + new_image.details = { + 'created_by': current_user.id, + 'embedding_similar': similar_image_ids, + 'recognition_result': {dict_level: exist_result}, + } + await image_dao.update( + db, new_image.id, + UpdateImageParam( + embedding=embedding, + info=metadata or {}, + details=new_image.details + ) + ) + return res + else: + new_image.details = { + 'created_by': current_user.id, + 'embedding_similar': similar_image_ids, + 'recognition_result': {}, + } + await image_dao.update( + db, new_image.id, + UpdateImageParam( + embedding=embedding, + info=metadata or {}, + details=new_image.details + ) + ) + exclude_words.extend([ + word for section in recognition_result.values() + for word in section.get('ref_word', []) + if isinstance(section.get('ref_word'), list) + ]) + + # recognize + recognize_params = QwenRecognizeImageParams( + user_id=current_user.id, + image_id=image_id, + file_name=file_name, + format=image_format_str, + data=base64_image, + type=type, + dict_level=dict_level, + exclude_words=list(set(exclude_words)) # 去重 + ) + + recognize_response = Qwen.recognize_image(recognize_params, background_tasks) + if recognize_response.get("error"): + raise Exception(recognize_response["error"]) + + recognition_result = recognize_response.get("result").strip().replace("```json", "").replace("```", "").strip() + + result = json.loads(recognition_result) + + # Transform the data structure from array of objects to grouped arrays + transformed_result = {} + for level_key, level_data in result.items(): + upper_level_key = str(level_key).upper() + if not isinstance(level_data, list): + # If it's not a list, keep the original structure + transformed_result[upper_level_key] = level_data + continue + + # Initialize the structure with empty lists + transformed_result[upper_level_key] = { + "description": [], + "desc_ipa": [], + "ref_word": [], + "word_ipa": [] + } + + # Populate the lists while maintaining order + for item in level_data: + if isinstance(item, dict): + transformed_result[upper_level_key]["description"].append(item.get("description", "")) + transformed_result[upper_level_key]["desc_ipa"].append(item.get("desc_ipa", "")) + transformed_result[upper_level_key]["ref_word"].append(item.get("ref_word", "")) + transformed_result[upper_level_key]["word_ipa"].append(item.get("word_ipa", "")) + + processed_result = {} + # 处理ref_word中的复数单词 + for level_key, level_data in transformed_result.items(): + upper_level_key = str(level_key).upper() + if not isinstance(level_data, dict): + continue + + if "ref_word" in level_data: + ref_words = level_data["ref_word"] + if isinstance(ref_words, list): + processed_ref_words = [] + for word in ref_words: + if isinstance(word, str): + # 调用异步方法获取处理后的单词(如转为单数) + processed_word = await ImageService._get_linked_word(word) + processed_ref_words.append(processed_word) + else: + processed_ref_words.append(word) + level_data["ref_word"] = processed_ref_words + processed_result[upper_level_key] = level_data + + # 保留原有的值 + if existing_image: + details = existing_image.details + details["recognition_result"] = processed_result + details["updated_by"] = current_user.id + else: + details = { + 'created_by': current_user.id, + "embedding_model": settings.QWEN_VISION_EMBEDDING_MODEL, + "recognize_model": settings.QWEN_VISION_MODEL, + "recognition_result": processed_result, + } + + await image_dao.update( + db, image_id, + UpdateImageParam( + details=details, + ) + ) + + return ImageRecognizeRes( + id=image_id, + res=details["recognition_result"][dict_level] + ) + + @staticmethod + async def find_image(id: int) -> Image: + async with async_db_session.begin() as db: + # 检查是否在image表中已有记录 + image = await image_dao.get(db, id) + return image + + @staticmethod + def wrap_exist_image_with_result(image: Image, result: dict) -> ImageRecognizeRes: + res = ImageRecognizeRes( + id=image.id, + res=result + ) + return res + + @staticmethod + async def _get_linked_word(word: str) -> str: + """ + 获取链接单词(使用dict_service中的缓存机制) + 如果单词是复数形式,获取其单数形式; + 如果单词在字典中有LINK=引用,获取引用的单词 + """ + try: + # 使用dict_service中的缓存机制获取链接单词 + return await dict_service.get_linked_word(word) + except Exception as e: + # 如果出现任何错误,返回原单词 + return word + + # @staticmethod + # async def download_image(id: int) -> dict: + # image: Image = image_service.load_image_by_id(id=id) + # fdata = LocalStorage.read(file_id=image.id) + # base64_image = base64.b64encode(fdata).decode('utf-8') + # params = QwenRecognizeImageParams(file_name=image.info.file_name, format=image.info.format.value.lower(), + # data=base64_image) + # recognize_response = Qwen.recognize_image(params) + # if (recognize_response.get("error")): + # raise Exception(recognize_response["error"]) + # + # return {"image": image} + + +image_service: ImageService = ImageService() diff --git a/backend/app/ai/service/image_text_service.py b/backend/app/ai/service/image_text_service.py new file mode 100644 index 0000000..5645578 --- /dev/null +++ b/backend/app/ai/service/image_text_service.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import logging +from typing import Optional, List + +from backend.app.ai.crud import recording_dao +from backend.app.ai.crud.image_text_crud import image_text_dao +from backend.app.ai.model.image_text import ImageText +from backend.app.ai.schema.image_text import CreateImageTextParam, UpdateImageTextParam, ImageTextInitResponseSchema, \ + ImageTextAssessmentSchema +from backend.app.ai.crud.image_curd import image_dao +from backend.database.db import async_db_session + +logger = logging.getLogger(__name__) + + +class ImageTextService: + + @staticmethod + async def get_text_by_id(text_id: int) -> Optional[ImageText]: + """ + 根据ID获取图片文本记录 + + :param text_id: 图片文本ID + :return: 图片文本记录 + """ + async with async_db_session() as db: + return await image_text_dao.get(db, text_id) + + @staticmethod + async def get_texts_by_image_id(image_id: int) -> List[ImageText]: + """ + 根据图片ID获取所有文本记录 + + :param image_id: 图片ID + :return: 图片文本记录列表 + """ + async with async_db_session() as db: + return await image_text_dao.get_by_image_id(db, image_id) + + @staticmethod + async def update_text(text_id: int, obj: UpdateImageTextParam) -> bool: + """ + 更新图片文本记录 + + :param text_id: 图片文本ID + :param obj: 图片文本更新参数 + :return: 是否更新成功 + """ + async with async_db_session.begin() as db: + return await image_text_dao.update(db, text_id, obj) + + @staticmethod + async def get_text_by_image_and_content(image_id: int, content: str) -> Optional[ImageText]: + """ + 根据图片ID和文本内容获取文本记录 + + :param image_id: 图片ID + :param content: 文本内容 + :return: 图片文本记录 + """ + async with async_db_session() as db: + return await image_text_dao.get_by_image_id_and_content(db, image_id, content) + + @staticmethod + async def set_standard_audio(text_id: int, audio_file_id: int) -> bool: + """ + 设置标准朗读音频 + + :param text_id: 图片文本ID + :param audio_file_id: 音频文件ID + :return: 是否设置成功 + """ + async with async_db_session.begin() as db: + return await image_text_dao.update( + db, text_id, + UpdateImageTextParam(standard_audio_id=audio_file_id) + ) + + @staticmethod + async def init_image_texts(user_id: int, image_id: int, dict_level: str, background_tasks=None) -> ImageTextInitResponseSchema: + """ + 初始化图片文本记录 + 根据dict_level从image的recognition_result中提取文本,如果不存在则创建,如果已存在则直接返回 + + :param user_id: 用户ID + :param image_id: 图片ID + :param dict_level: 词典等级 + :param background_tasks: 后台任务对象,用于异步生成标准发音音频 + :return: 图片文本记录列表 + """ + async with async_db_session() as db: + # 获取图片记录 + image = await image_dao.get(db, image_id) + if not image: + raise ValueError(f"Image with id {image_id} not found") + + # 检查details和recognition_result是否存在 + if not image.details or "recognition_result" not in image.details: + raise ValueError("Image recognition result not found") + + recognition_result = image.details["recognition_result"] + upper_dict_level = dict_level.upper() + + if upper_dict_level not in recognition_result: + raise ValueError(f"Dict level {dict_level} not found in recognition result") + + level_data = recognition_result[upper_dict_level] + + # 收集该等级下的所有文本及来源 + texts_with_source_and_ipa = [] # [(content, source, ipa), ...] + + # 提取description中的文本和对应的IPA + if "description" in level_data and "desc_ipa" in level_data: + descriptions = level_data["description"] + desc_ipas = level_data["desc_ipa"] + + # 如果description是列表,处理每个元素及对应的IPA + if isinstance(descriptions, list): + for i, desc in enumerate(descriptions): + if isinstance(desc, str): + # 获取对应的IPA,如果存在的话 + ipa = None + if isinstance(desc_ipas, list) and i < len(desc_ipas): + ipa_value = desc_ipas[i] + if isinstance(ipa_value, str): + ipa = ipa_value + texts_with_source_and_ipa.append((desc, "description", ipa)) + # 如果description是字符串,直接添加 + elif isinstance(descriptions, str): + # 获取对应的IPA,如果存在的话 + ipa = None + if isinstance(desc_ipas, list) and len(desc_ipas) > 0: + ipa_value = desc_ipas[0] + if isinstance(ipa_value, str): + ipa = ipa_value + texts_with_source_and_ipa.append((descriptions, "description", ipa)) + + # 获取已存在的文本记录 + existing_texts = await image_text_dao.get_by_image_id(db, image_id) + existing_text_map = {text.content: text for text in existing_texts} + + # 创建新的文本记录(如果不存在) + created_texts = [] + newly_created_texts = [] + for text_content, source, ipa in texts_with_source_and_ipa: + if text_content in existing_text_map: + # 已存在的文本记录 + created_texts.append(existing_text_map[text_content]) + else: + # 创建新的文本记录 + new_text = ImageText( + image_id=image_id, + article_sentence_id=None, + content=text_content, + standard_audio_id=None, + source=source, + dict_level=dict_level, + ipa=ipa + ) + db.add(new_text) + created_texts.append(new_text) + newly_created_texts.append(new_text) + + # 提交事务 + await db.commit() + + # 刷新创建的文本记录 + for text in created_texts: + if text.id is None: # 只刷新新创建的记录 + await db.refresh(text) + + # 为新创建的文本记录生成标准发音音频(使用后台任务) + if background_tasks and newly_created_texts: + from backend.middleware.tencent_cloud import TencentCloud + tencent_cloud = TencentCloud() + for text in newly_created_texts: + # 添加后台任务来生成标准发音音频 + background_tasks.add_task( + tencent_cloud.text_to_speak, + image_id=text.image_id, + content=text.content, + image_text_id=text.id, + user_id=user_id + ) + + # 构造返回结构 + assessments = [] + for text in created_texts: + latest_recording_details = None + latest_recording = await recording_dao.get_latest_by_text_id(db, text.id) + if latest_recording: + latest_recording_details = latest_recording.details + assessment = ImageTextAssessmentSchema( + id=str(text.id), + ipa=text.ipa.replace('/', ''), + content=text.content, + details=latest_recording_details, + ) + assessments.append(assessment) + + return ImageTextInitResponseSchema( + image_file_id=str(image.file_id), + assessments=assessments + ) + + +image_text_service = ImageTextService() \ No newline at end of file diff --git a/backend/app/ai/service/recording_service.py b/backend/app/ai/service/recording_service.py new file mode 100644 index 0000000..fa9d135 --- /dev/null +++ b/backend/app/ai/service/recording_service.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import logging +import time +from typing import Optional, List, Dict, Any +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.ai.model.recording import Recording +from backend.app.ai.model.image_text import ImageText +from backend.app.ai.schema.recording import RecordingMetadata, RecordingFormat, RecordingAssessmentResult, RecordingAssessmentWord, RecordingAssessmentFeedback, RecordingScore, ReadingProgressResponse +from backend.app.admin.crud.file_crud import file_dao +from backend.app.admin.model.file import File +from backend.app.admin.model.wx_user import WxUser +from backend.app.admin.schema.audit_log import CreateAuditLogParam +from backend.app.admin.service.audit_log_service import audit_log_service +from backend.database.db import async_db_session +from backend.middleware.tencent_cloud import TencentCloud + +# Import the recording_dao for accessing recording CRUD methods +from backend.app.ai.crud.recording_crud import recording_dao + +# Import the image_text_service for fetching image text records +from backend.app.ai.service.image_text_service import image_text_service + +logger = logging.getLogger(__name__) + + +class RecordingService: + def __init__(self): + self.tencent_cloud = TencentCloud() + + @staticmethod + async def get_recording_by_file_id(file_id: int) -> Optional[Recording]: + """根据文件ID获取录音记录""" + try: + async with async_db_session() as db: + result = await db.execute(select(Recording).where(Recording.file_id == file_id)) + return result.scalar_one_or_none() + except Exception as e: + logger.error(f"Failed to get recording by file_id {file_id}: {e}") + return None + + @staticmethod + async def get_recordings_by_text_id(text_id: int) -> List[Recording]: + """根据文本ID获取所有录音记录(不包括标准音频)""" + async with async_db_session() as db: + stmt = select(Recording).where( + Recording.image_text_id == text_id, + Recording.is_standard == False + ).order_by(Recording.created_time.asc()) + result = await db.execute(stmt) + return list(result.scalars().all()) + + @staticmethod + async def get_standard_audio_file_id_by_text_id(text_id: int, max_wait_time: int = 30, retry_interval: int = 2) -> Optional[int]: + """根据文本ID获取标准音频的文件ID,支持等待机制""" + import asyncio + import time + + start_time = time.time() + while time.time() - start_time < max_wait_time: + async with async_db_session() as db: + recording = await recording_dao.get_standard_by_text_id(db, text_id) + if recording: + return recording.file_id + # 等待指定的时间间隔再重试 + await asyncio.sleep(retry_interval) + + # 超时后仍然没有找到,返回None + return None + + @staticmethod + async def get_standard_audio_recording_by_text_id(text_id: int) -> Optional[Recording]: + """根据文本ID获取标准音频记录""" + async with async_db_session() as db: + return await recording_dao.get_standard_by_text_id(db, text_id) + + @staticmethod + async def update_recording_details(id: int, details: dict) -> bool: + """更新录音的评估详情""" + try: + async with async_db_session.begin() as db: # 使用begin()确保事务正确处理 + recording = await db.get(Recording, id) + if recording: + recording.details = details + await db.commit() + return True + else: + logger.error(f"Recording with file_id {id} not found") + return False + except Exception as e: + logger.error(f"Failed to update recording details for file_id {id}: {e}") + raise RuntimeError(f"Failed to update recording details for file_id {id}: {str(e)}") + + @staticmethod + async def update_recording_text(file_id: int, text: str) -> bool: + """更新录音的文本内容""" + try: + async with async_db_session.begin() as db: # 使用begin()确保事务正确处理 + recording = await db.get(Recording, file_id) + if recording: + recording.text = text + await db.commit() + await db.refresh(recording) + return True + return False + except Exception as e: + logger.error(f"Failed to update recording text for file_id {file_id}: {e}") + raise RuntimeError(f"Failed to update recording text for file_id {file_id}: {str(e)}") + + @staticmethod + async def update_recording_image_id(file_id: int, image_id: int) -> bool: + """更新录音关联的图片ID""" + try: + async with async_db_session.begin() as db: # 使用begin()确保事务正确处理 + recording = await db.get(Recording, file_id) + if recording: + recording.image_id = image_id + await db.commit() + await db.refresh(recording) + return True + return False + except Exception as e: + logger.error(f"Failed to update recording image_id for file_id {file_id}: {e}") + raise RuntimeError(f"Failed to update recording image_id for file_id {file_id}: {str(e)}") + + @staticmethod + async def update_recording_text_id(file_id: int, text_id: int) -> bool: + """更新录音关联的图片文本ID""" + try: + async with async_db_session.begin() as db: # 使用begin()确保事务正确处理 + recording = await db.get(Recording, file_id) + if recording: + recording.image_text_id = text_id + await db.commit() + await db.refresh(recording) + return True + return False + except Exception as e: + logger.error(f"Failed to update recording text_id for file_id {file_id}: {e}") + raise RuntimeError(f"Failed to update recording text_id for file_id {file_id}: {str(e)}") + + @staticmethod + async def get_recordings_by_image_and_text(image_id: int, text: str) -> List[Recording]: + """获取特定图片和文本的所有录音记录,按创建时间排序""" + async with async_db_session() as db: + stmt = select(Recording).where( + Recording.image_id == image_id, + Recording.text == text, + Recording.is_standard == False + ).order_by(Recording.created_time.asc()) + result = await db.execute(stmt) + return list(result.scalars().all()) + + @staticmethod + def calculate_progress(recordings: List[Recording]) -> Dict[str, Any]: + """ + 计算朗读进步幅度 + :param recordings: 按时间顺序排列的录音记录列表 + :return: 包含进步统计信息的字典 + """ + if len(recordings) < 2: + return {"error": "至少需要两次录音才能计算进步幅度"} + + # 提取每次录音的评分 + scores = [] + for recording in recordings: + if recording.details and 'assessment' in recording.details: + assessment = recording.details['assessment'] + # 根据实际的评估结果结构提取分数 + # 这里假设评估结果中有 overall_score 字段 + if 'overall_score' in assessment: + scores.append(RecordingScore( + timestamp=recording.created_time, + overall_score=assessment['overall_score'], + pronunciation_score=assessment.get('pronunciation_score', 0), + fluency_score=assessment.get('fluency_score', 0), + integrity_score=assessment.get('integrity_score', 0) + )) + + if len(scores) < 2: + return {"error": "没有足够的有效评估结果来计算进步幅度"} + + # 计算进步幅度 + first_score = scores[0].overall_score + latest_score = scores[-1].overall_score + progress = latest_score - first_score + + # 计算平均进步幅度 + avg_progress = progress / (len(scores) - 1) if len(scores) > 1 else 0 + + # 找出最大分数和最小分数 + max_score = max(scores, key=lambda x: x.overall_score) + min_score = min(scores, key=lambda x: x.overall_score) + + return ReadingProgressResponse( + total_attempts=len(scores), + first_score=first_score, + latest_score=latest_score, + total_progress=progress, + average_progress_per_attempt=avg_progress, + max_score=max_score, + min_score=min_score, + all_scores=scores + ).dict() + + @staticmethod + def _extract_basic_metadata(file_record: File) -> RecordingMetadata: + """从文件记录中提取基本录音元数据""" + # 根据文件扩展名推测录音格式 + format_type = RecordingFormat.UNKNOWN + if file_record.file_name: + ext = file_record.file_name.lower().split('.')[-1] if '.' in file_record.file_name else '' + if ext in ['wav']: + format_type = RecordingFormat.WAV + elif ext in ['mp3']: + format_type = RecordingFormat.MP3 + elif ext in ['flac']: + format_type = RecordingFormat.FLAC + elif ext in ['aac']: + format_type = RecordingFormat.AAC + elif ext in ['ogg']: + format_type = RecordingFormat.OGG + elif ext in ['m4a']: + format_type = RecordingFormat.M4A + elif ext in ['wma']: + format_type = RecordingFormat.WMA + + # 创建基本元数据对象 + metadata = RecordingMetadata( + file_name=file_record.file_name, + content_type=file_record.content_type, + format=format_type, + file_size=file_record.file_size + ) + + return metadata + + @staticmethod + async def create_recording_record(file_id: int, ref_text: str = None, image_id: int = None, image_text_id: int = None, eval_mode: int = None, user_id: int = None) -> int: + """创建录音记录并提取基本元数据""" + try: + # 检查文件是否存在 + async with async_db_session() as db: + file_record = await file_dao.get(db, file_id) + if not file_record: + raise ValueError(f"File with id {file_id} not found") + + # 从文件记录中提取基本元数据 + metadata = RecordingService._extract_basic_metadata(file_record) + + # 创建录音记录 + recording = Recording( + file_id=file_id, + image_id=image_id, + image_text_id=image_text_id, + article_sentence_id=None, + eval_mode=eval_mode, + info=metadata, + text=ref_text, + user_id=user_id + ) + db.add(recording) + await db.commit() + await db.refresh(recording) + return recording.id + except Exception as e: + logger.error(f"Failed to create recording record for file_id {file_id}: {e}") + raise RuntimeError(f"Failed to create recording record for file_id {file_id}: {str(e)}") + + @staticmethod + async def create_recording_record_with_details(file_id: int, ref_text: str = None, image_id: int = None, image_text_id: int = None, eval_mode: int = None, user_id: int = None, details: dict = None, is_standard: bool = False) -> int: + """创建录音记录并设置详细信息""" + try: + # 检查文件是否存在 + async with async_db_session() as db: + file_record = await file_dao.get(db, file_id) + if not file_record: + raise ValueError(f"File with id {file_id} not found") + + # 从文件记录中提取基本元数据 + metadata = RecordingService._extract_basic_metadata(file_record) + + # 创建录音记录 + recording = Recording( + file_id=file_id, + image_id=image_id, + image_text_id=image_text_id, + article_sentence_id=None, + eval_mode=eval_mode, + info=metadata, + text=ref_text, + details=details, + is_standard=is_standard, # 设置标准音频标记 + user_id=user_id + ) + db.add(recording) + await db.commit() + await db.refresh(recording) + return recording.id + except Exception as e: + logger.error(f"Failed to create recording record with details for file_id {file_id}: {e}") + raise RuntimeError(f"Failed to create recording record with details for file_id {file_id}: {str(e)}") + + async def assess_recording(self, file_id: int, image_text_id: int, user_id: int = 0, background_tasks=None) -> dict: + """ + 评估录音文件(调用腾讯云SOE API获取评估结果) + + :param file_id: 录音文件ID + :param image_text_id: 关联的图片文本ID + :param user_id: 用户ID + :param background_tasks: 后台任务对象,用于异步记录审计日志 + :return: 评估结果 + """ + start_time = time.time() + status_code = 200 + error_message = None + + # 获取图片文本记录以获取ref_text和image_id + image_text = await image_text_service.get_text_by_id(image_text_id) + if not image_text: + raise ValueError(f"ImageText with id {image_text_id} not found") + + ref_text = image_text.content + image_id = image_text.image_id + + # 检查录音记录是否存在 + recording = await self.get_recording_by_file_id(file_id) + if not recording: + # 如果不存在,创建新的录音记录并存储ref_text和image_id + try: + recording_id = await self.create_recording_record(file_id, ref_text, image_id, image_text_id, 1, user_id) + # 重新获取recording对象 + recording = await self.get_recording_by_file_id(file_id) + if not recording: + raise RuntimeError(f"Failed to create recording record for file_id {file_id}") + except Exception as e: + raise RuntimeError(f"Failed to create recording record for file_id {file_id}: {str(e)}") + + try: + # 调用腾讯云SOE API进行语音评估 + result = await self.tencent_cloud.assessment_speech(file_id, ref_text, str(recording.id), image_id, user_id) + + # 保存完整的识别结果到details字段中 + details = {"assessment": result} + + # 更新录音记录的details字段 + success = await self.update_recording_details(recording.id, details) + if not success: + raise RuntimeError(f"Failed to update recording details for file_id {file_id}") + + # 计算耗时 + duration = time.time() - start_time + + # 记录审计日志 + if background_tasks: + self._log_audit(background_tasks, file_id, ref_text, result, duration, status_code, user_id, image_id, image_text_id) + + return details + + except Exception as e: + # 如果评估失败,记录错误并返回错误信息 + error_result = {"assessment": {"error": str(e)}} + + # 更新录音记录的details字段 + try: + await self.update_recording_details(recording.id, error_result) + except Exception as update_error: + logger.error(f"Failed to update recording details with error result for file_id {file_id}: {str(update_error)}") + + # 记录错误信息 + status_code = 500 + error_message = str(e) + + # 计算耗时 + duration = time.time() - start_time + + # 记录审计日志 + if background_tasks: + self._log_audit(background_tasks, file_id, ref_text, error_result, duration, status_code, user_id, image_id, image_text_id) + + # 重新抛出异常 + raise RuntimeError(f"Failed to assess recording: {str(e)}") + + @staticmethod + def _log_audit(background_tasks, file_id: int, ref_text: str, result: dict, + duration: float, status_code: int, user_id: int, image_id: int, image_text_id: int, error_message: str = None): + """ + 记录API调用审计日志 + + :param background_tasks: 后台任务对象 + :param file_id: 文件ID + :param ref_text: 参考文本 + :param result: API调用结果 + :param duration: 调用耗时(秒) + :param status_code: HTTP状态码 + :param user_id: 用户ID + :param image_id: 图片ID + :param image_text_id: 图片文本ID + :param error_message: 错误信息 + """ + # 提取token使用情况 + token_usage = {} + # if result and isinstance(result, dict): + # # SOE API的结果结构 + # token_usage = { + # "session_id": result.get("session_id"), + # "voice_id": result.get("voice_id") + # } + + # 计算成本 (假设每次调用固定成本) + cost = 0.004 # 4元/千次 + + # 构造请求数据 + request_data = { + "file_id": file_id, + "ref_text": ref_text, + "image_text_id": image_text_id + } + + # 构造响应数据 + response_data = result if result else {} + + # 创建审计日志参数 + audit_log = CreateAuditLogParam( + api_type="assessment", + model_name="tencent_soe", + request_data=request_data, + response_data=response_data, + token_usage=token_usage, + cost=cost, + duration=duration, + status_code=status_code, + error_message=error_message or "", + image_id=image_id or 0, + user_id=user_id, + api_version="v1", + ) + + # 添加后台任务 + background_tasks.add_task( + audit_log_service.create, + obj=audit_log, + ) + + # async def assess_recordings_batch(self, requests: list, user_id: int = 0, background_tasks=None) -> list: + # """ + # 批量评估录音文件 + # + # :param requests: 请求列表,每个元素包含 {'file_id': int, 'ref_text': str, 'voice_id': str} + # :param user_id: 用户ID + # :param background_tasks: 后台任务对象,用于异步记录审计日志 + # :return: 评估结果列表 + # """ + # try: + # # 调用腾讯云SOE API进行批量语音评估 + # results = await self.tencent_cloud.recognize_speech_batch(requests) + # + # # 更新每个录音记录的details字段 + # for result in results: + # if "error" in result: + # # 如果评估失败,记录错误信息 + # error_result = {"assessment": result} + # file_id = result.get("file_id") + # if file_id: + # await self.update_recording_details(file_id, error_result) + # else: + # # 如果评估成功,保存完整结果 + # file_id = result.get("file_id") or result.get("results", [{}])[0].get("file_id") + # if file_id: + # # 保存完整的识别结果到details字段中 + # success_result = {"assessment": result} + # await self.update_recording_details(file_id, success_result) + # + # return results + # + # except Exception as e: + # raise RuntimeError(f"Failed to assess recordings batch: {str(e)}") + + +recording_service = RecordingService() \ No newline at end of file diff --git a/backend/app/router.py b/backend/app/router.py new file mode 100755 index 0000000..2807cef --- /dev/null +++ b/backend/app/router.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import APIRouter + +from backend.app.admin.api.router import v1 as admin_v1 +from backend.app.ai.api.router import v1 as ai_v1 + +router = APIRouter() + +router.include_router(admin_v1) +router.include_router(ai_v1) diff --git a/backend/common/__init__.py b/backend/common/__init__.py new file mode 100755 index 0000000..6f7b18c --- /dev/null +++ b/backend/common/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/common/dataclasses.py b/backend/common/dataclasses.py new file mode 100755 index 0000000..d9aec4a --- /dev/null +++ b/backend/common/dataclasses.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import dataclasses + +from datetime import datetime + +from fastapi import Response + +from backend.common.enums import StatusType + + +@dataclasses.dataclass +class IpInfo: + ip: str + country: str | None + region: str | None + city: str | None + + +@dataclasses.dataclass +class UserAgentInfo: + user_agent: str + os: str | None + browser: str | None + device: str | None + + +@dataclasses.dataclass +class RequestCallNext: + code: str + msg: str + status: StatusType + err: Exception | None + response: Response + + +@dataclasses.dataclass +class AccessToken: + access_token: str + access_token_expire_time: datetime + session_uuid: str + + +@dataclasses.dataclass +class RefreshToken: + refresh_token: str + refresh_token_expire_time: datetime + + +@dataclasses.dataclass +class NewToken: + new_access_token: str + new_access_token_expire_time: datetime + new_refresh_token: str + new_refresh_token_expire_time: datetime + session_uuid: str + + +@dataclasses.dataclass +class TokenPayload: + id: int + session_uuid: str + expire_time: datetime + + +@dataclasses.dataclass +class UploadUrl: + url: str + + +@dataclasses.dataclass +class SnowflakeInfo: + timestamp: int + datetime: str + cluster_id: int + node_id: int + sequence: int diff --git a/backend/common/enums.py b/backend/common/enums.py new file mode 100755 index 0000000..4d42982 --- /dev/null +++ b/backend/common/enums.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from enum import Enum +from enum import IntEnum as SourceIntEnum +from typing import Any, Type, TypeVar + +T = TypeVar('T', bound=Enum) + + +class _EnumBase: + """枚举基类,提供通用方法""" + + @classmethod + def get_member_keys(cls: Type[T]) -> list[str]: + """获取枚举成员名称列表""" + return [name for name in cls.__members__.keys()] + + @classmethod + def get_member_values(cls: Type[T]) -> list: + """获取枚举成员值列表""" + return [item.value for item in cls.__members__.values()] + + @classmethod + def get_member_dict(cls: Type[T]) -> dict[str, Any]: + """获取枚举成员字典""" + return {name: item.value for name, item in cls.__members__.items()} + + +class IntEnum(_EnumBase, SourceIntEnum): + """整型枚举基类""" + + pass + + +class StrEnum(_EnumBase, str, Enum): + """字符串枚举基类""" + + pass + + +class MenuType(IntEnum): + """菜单类型""" + + directory = 0 + menu = 1 + button = 2 + embedded = 3 + link = 4 + + +class RoleDataRuleOperatorType(IntEnum): + """数据规则运算符""" + + AND = 0 + OR = 1 + + +class RoleDataRuleExpressionType(IntEnum): + """数据规则表达式""" + + eq = 0 # == + ne = 1 # != + gt = 2 # > + ge = 3 # >= + lt = 4 # < + le = 5 # <= + in_ = 6 + not_in = 7 + + +class MethodType(StrEnum): + """HTTP 请求方法""" + + GET = 'GET' + POST = 'POST' + PUT = 'PUT' + DELETE = 'DELETE' + PATCH = 'PATCH' + OPTIONS = 'OPTIONS' + + +class LoginLogStatusType(IntEnum): + """登录日志状态""" + + fail = 0 + success = 1 + + +class BuildTreeType(StrEnum): + """构建树形结构类型""" + + traversal = 'traversal' + recursive = 'recursive' + + +class OperaLogCipherType(IntEnum): + """操作日志加密类型""" + + aes = 0 + md5 = 1 + itsdangerous = 2 + plan = 3 + + +class StatusType(IntEnum): + """状态类型""" + + disable = 0 + enable = 1 + + +class UserSocialType(StrEnum): + """用户社交类型""" + + github = 'GitHub' + linux_do = 'LinuxDo' + + +class FileType(StrEnum): + """文件类型""" + + image = 'image' + video = 'video' + + +class PluginType(StrEnum): + """插件类型""" + + zip = 'zip' + git = 'git' + + +class UserPermissionType(StrEnum): + """用户权限类型""" + + superuser = 'superuser' + staff = 'staff' + status = 'status' + multi_login = 'multi_login' diff --git a/backend/common/exception/__init__.py b/backend/common/exception/__init__.py new file mode 100755 index 0000000..6f7b18c --- /dev/null +++ b/backend/common/exception/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/common/exception/errors.py b/backend/common/exception/errors.py new file mode 100755 index 0000000..8cf5009 --- /dev/null +++ b/backend/common/exception/errors.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Any + +from fastapi import HTTPException +from starlette.background import BackgroundTask + +from backend.common.response.response_code import CustomErrorCode, StandardResponseCode + + +class BaseExceptionMixin(Exception): + """基础异常混入类""" + + code: int + + def __init__(self, *, msg: str = None, data: Any = None, background: BackgroundTask | None = None): + self.msg = msg + self.data = data + # The original background task: https://www.starlette.io/background/ + self.background = background + + +class HTTPError(HTTPException): + """HTTP 异常""" + + def __init__(self, *, code: int, msg: Any = None, headers: dict[str, Any] | None = None): + super().__init__(status_code=code, detail=msg, headers=headers) + + +class CustomError(BaseExceptionMixin): + """自定义异常""" + + def __init__(self, *, error: CustomErrorCode, data: Any = None, background: BackgroundTask | None = None): + self.code = error.code + super().__init__(msg=error.msg, data=data, background=background) + + +class RequestError(BaseExceptionMixin): + """请求异常""" + + code = StandardResponseCode.HTTP_400 + + def __init__(self, *, msg: str = 'Bad Request', data: Any = None, background: BackgroundTask | None = None): + super().__init__(msg=msg, data=data, background=background) + + +class ForbiddenError(BaseExceptionMixin): + """禁止访问异常""" + + code = StandardResponseCode.HTTP_403 + + def __init__(self, *, msg: str = 'Forbidden', data: Any = None, background: BackgroundTask | None = None): + super().__init__(msg=msg, data=data, background=background) + + +class NotFoundError(BaseExceptionMixin): + """资源不存在异常""" + + code = StandardResponseCode.HTTP_404 + + def __init__(self, *, msg: str = 'Not Found', data: Any = None, background: BackgroundTask | None = None): + super().__init__(msg=msg, data=data, background=background) + + +class ServerError(BaseExceptionMixin): + """服务器异常""" + + code = StandardResponseCode.HTTP_500 + + def __init__( + self, *, msg: str = 'Internal Server Error', data: Any = None, background: BackgroundTask | None = None + ): + super().__init__(msg=msg, data=data, background=background) + + +class GatewayError(BaseExceptionMixin): + """网关异常""" + + code = StandardResponseCode.HTTP_502 + + def __init__(self, *, msg: str = 'Bad Gateway', data: Any = None, background: BackgroundTask | None = None): + super().__init__(msg=msg, data=data, background=background) + + +class AuthorizationError(BaseExceptionMixin): + """授权异常""" + + code = StandardResponseCode.HTTP_401 + + def __init__(self, *, msg: str = 'Permission Denied', data: Any = None, background: BackgroundTask | None = None): + super().__init__(msg=msg, data=data, background=background) + + +class TokenError(HTTPError): + """Token 异常""" + + code = StandardResponseCode.HTTP_401 + + def __init__(self, *, msg: str = 'Not Authenticated', headers: dict[str, Any] | None = None): + super().__init__(code=self.code, msg=msg, headers=headers or {'WWW-Authenticate': 'Bearer'}) + +class ConflictError(BaseExceptionMixin): + """资源冲突异常""" + + code = StandardResponseCode.HTTP_409 + + def __init__(self, *, msg: str = 'Conflict', data: Any = None, background: BackgroundTask | None = None): + super().__init__(msg=msg, data=data, background=background) \ No newline at end of file diff --git a/backend/common/exception/exception_handler.py b/backend/common/exception/exception_handler.py new file mode 100755 index 0000000..032400b --- /dev/null +++ b/backend/common/exception/exception_handler.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError +from starlette.exceptions import HTTPException +from starlette.middleware.cors import CORSMiddleware +from uvicorn.protocols.http.h11_impl import STATUS_PHRASES + +from backend.common.exception.errors import BaseExceptionMixin +from backend.common.response.response_code import CustomResponseCode, StandardResponseCode +from backend.common.response.response_schema import response_base +from backend.common.schema import CUSTOM_VALIDATION_ERROR_MESSAGES +from backend.core.conf import settings +from backend.utils.serializers import MsgSpecJSONResponse + + +def _get_exception_code(status_code: int): + """ + 获取返回状态码, OpenAPI, Uvicorn... 可用状态码基于 RFC 定义, 详细代码见下方链接 + + `python 状态码标准支持 `__ + + `IANA 状态码注册表 `__ + + :param status_code: + :return: + """ + try: + STATUS_PHRASES[status_code] + except Exception: + code = StandardResponseCode.HTTP_400 + else: + code = status_code + return code + + +async def _validation_exception_handler(request: Request, e: RequestValidationError | ValidationError): + """ + 数据验证异常处理 + + :param e: + :return: + """ + errors = [] + for error in e.errors(): + custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type']) + if custom_message: + ctx = error.get('ctx') + if not ctx: + error['msg'] = custom_message + else: + error['msg'] = custom_message.format(**ctx) + ctx_error = ctx.get('error') + if ctx_error: + error['ctx']['error'] = ( + ctx_error.__str__().replace("'", '"') if isinstance(ctx_error, Exception) else None + ) + errors.append(error) + error = errors[0] + if error.get('type') == 'json_invalid': + message = 'json解析失败' + else: + error_input = error.get('input') + field = str(error.get('loc')[-1]) + error_msg = error.get('msg') + message = f'{field} {error_msg},输入:{error_input}' if settings.ENVIRONMENT == 'dev' else error_msg + msg = f'请求参数非法: {message}' + data = {'errors': errors} if settings.ENVIRONMENT == 'dev' else None + content = { + 'code': StandardResponseCode.HTTP_422, + 'msg': msg, + 'data': data, + } + return MsgSpecJSONResponse(status_code=422, content=content) + + +def register_exception(app: FastAPI): + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + """ + 全局HTTP异常处理 + + :param request: + :param exc: + :return: + """ + if settings.ENVIRONMENT == 'dev': + content = { + 'code': exc.status_code, + 'msg': exc.detail, + 'data': None, + } + else: + res = response_base.fail(res=CustomResponseCode.HTTP_400) + content = res.model_dump() + return MsgSpecJSONResponse( + status_code=_get_exception_code(exc.status_code), + content=content, + headers=exc.headers, + ) + + @app.exception_handler(RequestValidationError) + async def fastapi_validation_exception_handler(request: Request, exc: RequestValidationError): + """ + fastapi 数据验证异常处理 + + :param request: + :param exc: + :return: + """ + return await _validation_exception_handler(request, exc) + + @app.exception_handler(ValidationError) + async def pydantic_validation_exception_handler(request: Request, exc: ValidationError): + """ + pydantic 数据验证异常处理 + + :param request: + :param exc: + :return: + """ + return await _validation_exception_handler(request, exc) + + @app.exception_handler(AssertionError) + async def assertion_error_handler(request: Request, exc: AssertionError): + """ + 断言错误处理 + + :param request: + :param exc: + :return: + """ + if settings.ENVIRONMENT == 'dev': + content = { + 'code': StandardResponseCode.HTTP_500, + 'msg': str(''.join(exc.args) if exc.args else exc.__doc__), + 'data': None, + } + else: + res = response_base.fail(res=CustomResponseCode.HTTP_500) + content = res.model_dump() + return MsgSpecJSONResponse( + status_code=StandardResponseCode.HTTP_500, + content=content, + ) + + @app.exception_handler(BaseExceptionMixin) + async def custom_exception_handler(request: Request, exc: BaseExceptionMixin): + """ + 全局自定义异常处理 + + :param request: + :param exc: + :return: + """ + content = { + 'code': exc.code, + 'msg': str(exc.msg), + 'data': exc.data if exc.data else None, + } + return MsgSpecJSONResponse( + status_code=_get_exception_code(exc.code), + content=content, + background=exc.background, + ) + + @app.exception_handler(Exception) + async def all_unknown_exception_handler(request: Request, exc: Exception): + """ + 全局未知异常处理 + + :param request: + :param exc: + :return: + """ + if settings.ENVIRONMENT == 'dev': + content = { + 'code': StandardResponseCode.HTTP_500, + 'msg': str(exc), + 'data': None, + } + else: + res = response_base.fail(res=CustomResponseCode.HTTP_500) + content = res.model_dump() + return MsgSpecJSONResponse( + status_code=StandardResponseCode.HTTP_500, + content=content, + ) + + if settings.MIDDLEWARE_CORS: + + @app.exception_handler(StandardResponseCode.HTTP_500) + async def cors_custom_code_500_exception_handler(request, exc): + """ + 跨域自定义 500 异常处理 + + `Related issue `_ + `Solution `_ + + :param request: + :param exc: + :return: + """ + if isinstance(exc, BaseExceptionMixin): + content = { + 'code': exc.code, + 'msg': exc.msg, + 'data': exc.data, + } + else: + if settings.ENVIRONMENT == 'dev': + content = { + 'code': StandardResponseCode.HTTP_500, + 'msg': str(exc), + 'data': None, + } + else: + res = response_base.fail(res=CustomResponseCode.HTTP_500) + content = res.model_dump() + response = MsgSpecJSONResponse( + status_code=exc.code if isinstance(exc, BaseExceptionMixin) else StandardResponseCode.HTTP_500, + content=content, + background=exc.background if isinstance(exc, BaseExceptionMixin) else None, + ) + origin = request.headers.get('origin') + if origin: + cors = CORSMiddleware( + app=app, + allow_origins=settings.CORS_ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], + expose_headers=settings.CORS_EXPOSE_HEADERS, + ) + response.headers.update(cors.simple_headers) + has_cookie = 'cookie' in request.headers + if cors.allow_all_origins and has_cookie: + response.headers['Access-Control-Allow-Origin'] = origin + elif not cors.allow_all_origins and cors.is_allowed_origin(origin=origin): + response.headers['Access-Control-Allow-Origin'] = origin + response.headers.add_vary_header('Origin') + return response diff --git a/backend/common/log.py b/backend/common/log.py new file mode 100755 index 0000000..684f7a4 --- /dev/null +++ b/backend/common/log.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import inspect +import logging +import os +import sys + +from asgi_correlation_id import correlation_id +from loguru import logger + +from backend.core import path_conf +from backend.core.conf import settings + + +class InterceptHandler(logging.Handler): + """ + 日志拦截处理器,用于将标准库的日志重定向到 loguru + + 参考:https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging + """ + + def emit(self, record: logging.LogRecord): + # 获取对应的 Loguru 级别(如果存在) + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # 查找记录日志消息的调用者 + frame, depth = inspect.currentframe(), 0 + while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def setup_logging() -> None: + """ + 设置日志处理器 + + 参考: + - https://github.com/benoitc/gunicorn/issues/1572#issuecomment-638391953 + - https://github.com/pawamoy/pawamoy.github.io/issues/17 + """ + # 设置根日志处理器和级别 + logging.root.handlers = [InterceptHandler()] + logging.root.setLevel(settings.LOG_STD_LEVEL) + + # 配置日志传播规则 + for name in logging.root.manager.loggerDict.keys(): + logging.getLogger(name).handlers = [] + if 'uvicorn.access' in name or 'watchfiles.main' in name: + logging.getLogger(name).propagate = False + else: + logging.getLogger(name).propagate = True + + # Debug log handlers + # logging.debug(f'{logging.getLogger(name)}, {logging.getLogger(name).propagate}') + + # 定义 correlation_id 默认过滤函数 + # https://github.com/snok/asgi-correlation-id/issues/7 + def correlation_id_filter(record): + cid = correlation_id.get(settings.LOG_CID_DEFAULT_VALUE) + record['correlation_id'] = cid[: settings.LOG_CID_UUID_LENGTH] + return record + + # 配置 loguru 处理器 + logger.remove() # 移除默认处理器 + logger.configure( + handlers=[ + { + 'sink': sys.stdout, + 'level': settings.LOG_STD_LEVEL, + 'filter': lambda record: correlation_id_filter(record), + 'format': settings.LOG_STD_FORMAT, + } + ] + ) + + +def set_custom_logfile(): + """设置自定义日志文件""" + log_path = path_conf.LOG_DIR + if not os.path.exists(log_path): + os.mkdir(log_path) + + # 日志文件 + log_access_file = os.path.join(log_path, settings.LOG_ACCESS_FILENAME) + log_error_file = os.path.join(log_path, settings.LOG_ERROR_FILENAME) + + # 日志文件通用配置 + # https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add + log_config = { + 'format': settings.LOG_FILE_FORMAT, + 'enqueue': True, + 'rotation': '5 MB', + 'retention': '7 days', + 'compression': 'tar.gz', + } + + # 标准输出文件 + logger.add( + str(log_access_file), + level=settings.LOG_ACCESS_FILE_LEVEL, + filter=lambda record: record['level'].no <= 25, + backtrace=False, + diagnose=False, + **log_config, + ) + + # 标准错误文件 + logger.add( + str(log_error_file), + level=settings.LOG_ERROR_FILE_LEVEL, + filter=lambda record: record['level'].no >= 30, + backtrace=True, + diagnose=True, + **log_config, + ) + + +log = logger diff --git a/backend/common/model.py b/backend/common/model.py new file mode 100755 index 0000000..3b928fe --- /dev/null +++ b/backend/common/model.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime +from typing import Annotated + +from sqlalchemy import BigInteger, DateTime +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column + +from backend.utils.snowflake import snowflake +from backend.utils.timezone import timezone + + +# 通用 Mapped 类型主键, 需手动添加,参考以下使用方式 +# MappedBase -> id: Mapped[id_key] +# DataClassBase && Base -> id: Mapped[id_key] = mapped_column(init=False) +id_key = Annotated[ + int, + mapped_column( + BigInteger, + primary_key=True, + unique=True, + index=True, + autoincrement=True, + sort_order=-999, + comment='主键 ID', + ), +] + + +# 雪花算法 Mapped 类型主键,使用方法与 id_key 相同 +# 详情:https://fastapi-practices.github.io/fastapi_best_architecture_docs/backend/reference/pk.html +snowflake_id_key = Annotated[ + int, + mapped_column( + BigInteger, + primary_key=True, + unique=True, + index=True, + default=snowflake.generate, + sort_order=-999, + comment='雪花算法主键 ID', + ), +] + +# Mixin: 一种面向对象编程概念, 使结构变得更加清晰, `Wiki `__ +class UserMixin(MappedAsDataclass): + """用户 Mixin 数据类""" + + created_by: Mapped[int] = mapped_column(sort_order=998, comment='创建者') + updated_by: Mapped[int | None] = mapped_column(init=False, default=None, sort_order=998, comment='修改者') + + +class DateTimeMixin(MappedAsDataclass): + """日期时间 Mixin 数据类""" + + created_time: Mapped[datetime] = mapped_column( + DateTime(timezone=True), init=False, default_factory=timezone.now, sort_order=999, comment='创建时间' + ) + updated_time: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), init=False, onupdate=timezone.now, sort_order=999, comment='更新时间' + ) + + + +class MappedBase(AsyncAttrs, DeclarativeBase): + """ + 声明式基类, 作为所有基类或数据模型类的父类而存在 + + `AsyncAttrs `__ + `DeclarativeBase `__ + `mapped_column() `__ + """ + + @declared_attr.directive + def __tablename__(cls) -> str: + return cls.__name__.lower() + + +class DataClassBase(MappedAsDataclass, MappedBase): + """ + 声明性数据类基类, 它将带有数据类集成, 允许使用更高级配置, 但你必须注意它的一些特性, 尤其是和 DeclarativeBase 一起使用时 + + `MappedAsDataclass `__ + """ # noqa: E501 + + __abstract__ = True + + +class Base(DataClassBase, DateTimeMixin): + """ + 声明性 Mixin 数据类基类, 带有数据类集成, 并包含 MiXin 数据类基础表结构, 你可以简单的理解它为含有基础表结构的数据类基类 + """ # noqa: E501 + + __abstract__ = True diff --git a/backend/common/pagination.py b/backend/common/pagination.py new file mode 100755 index 0000000..5c34faf --- /dev/null +++ b/backend/common/pagination.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from __future__ import annotations + +from math import ceil +from typing import TYPE_CHECKING, Generic, Sequence, TypeVar + +from fastapi import Depends, Query +from fastapi_pagination import pagination_ctx +from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams +from fastapi_pagination.ext.sqlalchemy import paginate +from fastapi_pagination.links.bases import create_links +from pydantic import BaseModel, Field + +if TYPE_CHECKING: + from sqlalchemy import Select + from sqlalchemy.ext.asyncio import AsyncSession + +T = TypeVar('T') +SchemaT = TypeVar('SchemaT') + + +class _CustomPageParams(BaseModel, AbstractParams): + page: int = Query(1, ge=1, description='Page number') + size: int = Query(20, gt=0, le=100, description='Page size') # 默认 20 条记录 + + def to_raw_params(self) -> RawParams: + return RawParams( + limit=self.size, + offset=self.size * (self.page - 1), + ) + + +class _Links(BaseModel): + first: str = Field(..., description='首页链接') + last: str = Field(..., description='尾页链接') + self: str = Field(..., description='当前页链接') + next: str | None = Field(None, description='下一页链接') + prev: str | None = Field(None, description='上一页链接') + + +class _PageDetails(BaseModel): + items: list = Field([], description='当前页数据') + total: int = Field(..., description='总条数') + page: int = Field(..., description='当前页') + size: int = Field(..., description='每页数量') + total_pages: int = Field(..., description='总页数') + links: _Links + + +class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]): + __params_type__ = _CustomPageParams + + @classmethod + def create( + cls, + items: list, + total: int, + params: _CustomPageParams, + ) -> _CustomPage[T]: + page = params.page + size = params.size + total_pages = ceil(total / params.size) + links = create_links( + first={'page': 1, 'size': size}, + last={'page': f'{ceil(total / params.size)}', 'size': size} if total > 0 else {'page': 1, 'size': size}, + next={'page': f'{page + 1}', 'size': size} if (page + 1) <= total_pages else None, + prev={'page': f'{page - 1}', 'size': size} if (page - 1) >= 1 else None, + ).model_dump() + + return cls( + items=items, + total=total, + page=params.page, + size=params.size, + total_pages=total_pages, + links=links, # type: ignore + ) + + +class PageData(_PageDetails, Generic[SchemaT]): + """ + 包含 data schema 的统一返回模型,适用于分页接口 + + E.g. :: + + @router.get('/test', response_model=ResponseSchemaModel[PageData[GetApiDetail]]) + def test(): + return ResponseSchemaModel[PageData[GetApiDetail]](data=GetApiDetail(...)) + + + @router.get('/test') + def test() -> ResponseSchemaModel[PageData[GetApiDetail]]: + return ResponseSchemaModel[PageData[GetApiDetail]](data=GetApiDetail(...)) + + + @router.get('/test') + def test() -> ResponseSchemaModel[PageData[GetApiDetail]]: + res = CustomResponseCode.HTTP_200 + return ResponseSchemaModel[PageData[GetApiDetail]](code=res.code, msg=res.msg, data=GetApiDetail(...)) + """ + + items: Sequence[SchemaT] + + +async def paging_data(db: AsyncSession, select: Select) -> dict: + """ + 基于 SQLAlchemy 创建分页数据 + + :param db: + :param select: + :return: + """ + paginated_data: _CustomPage = await paginate(db, select) + page_data = paginated_data.model_dump() + return page_data + + +# 分页依赖注入 +DependsPagination = Depends(pagination_ctx(_CustomPage)) diff --git a/backend/common/response/__init__.py b/backend/common/response/__init__.py new file mode 100755 index 0000000..6f7b18c --- /dev/null +++ b/backend/common/response/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/common/response/response_code.py b/backend/common/response/response_code.py new file mode 100755 index 0000000..8a02bfc --- /dev/null +++ b/backend/common/response/response_code.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import dataclasses + +from enum import Enum + + +class CustomCodeBase(Enum): + """自定义状态码基类""" + + @property + def code(self): + """ + 获取状态码 + """ + return self.value[0] + + @property + def msg(self): + """ + 获取状态码信息 + """ + return self.value[1] + + +class CustomResponseCode(CustomCodeBase): + """自定义响应状态码""" + + HTTP_200 = (200, 'OK') + HTTP_201 = (201, 'Created') + HTTP_202 = (202, 'Accepted') + HTTP_204 = (204, 'No Content') + HTTP_400 = (400, 'Bad Request') + HTTP_401 = (401, 'Unauthorized') + HTTP_403 = (403, 'Forbidden') + HTTP_404 = (404, 'Not Found') + HTTP_410 = (410, 'Gone') + HTTP_422 = (422, 'Unprocessable Entity') + HTTP_425 = (425, 'Too Early') + HTTP_429 = (429, 'Too Many Requests') + HTTP_500 = (500, 'Internal Server Error') + HTTP_502 = (502, 'Bad Gateway') + HTTP_503 = (503, 'Service Unavailable') + HTTP_504 = (504, 'Gateway Timeout') + + +class CustomErrorCode(CustomCodeBase): + """自定义错误状态码""" + + CAPTCHA_ERROR = (40001, '验证码错误') + + +@dataclasses.dataclass +class CustomResponse: + """ + 提供开放式响应状态码,而不是枚举,如果你想自定义响应信息,这可能很有用 + """ + + code: int + msg: str + + +class StandardResponseCode: + """标准响应状态码""" + + """ + HTTP codes + See HTTP Status Code Registry: + https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + + And RFC 2324 - https://tools.ietf.org/html/rfc2324 + """ + HTTP_100 = 100 # CONTINUE: 继续 + HTTP_101 = 101 # SWITCHING_PROTOCOLS: 协议切换 + HTTP_102 = 102 # PROCESSING: 处理中 + HTTP_103 = 103 # EARLY_HINTS: 提示信息 + HTTP_200 = 200 # OK: 请求成功 + HTTP_201 = 201 # CREATED: 已创建 + HTTP_202 = 202 # ACCEPTED: 已接受 + HTTP_203 = 203 # NON_AUTHORITATIVE_INFORMATION: 非权威信息 + HTTP_204 = 204 # NO_CONTENT: 无内容 + HTTP_205 = 205 # RESET_CONTENT: 重置内容 + HTTP_206 = 206 # PARTIAL_CONTENT: 部分内容 + HTTP_207 = 207 # MULTI_STATUS: 多状态 + HTTP_208 = 208 # ALREADY_REPORTED: 已报告 + HTTP_226 = 226 # IM_USED: 使用了 + HTTP_300 = 300 # MULTIPLE_CHOICES: 多种选择 + HTTP_301 = 301 # MOVED_PERMANENTLY: 永久移动 + HTTP_302 = 302 # FOUND: 临时移动 + HTTP_303 = 303 # SEE_OTHER: 查看其他位置 + HTTP_304 = 304 # NOT_MODIFIED: 未修改 + HTTP_305 = 305 # USE_PROXY: 使用代理 + HTTP_307 = 307 # TEMPORARY_REDIRECT: 临时重定向 + HTTP_308 = 308 # PERMANENT_REDIRECT: 永久重定向 + HTTP_400 = 400 # BAD_REQUEST: 请求错误 + HTTP_401 = 401 # UNAUTHORIZED: 未授权 + HTTP_402 = 402 # PAYMENT_REQUIRED: 需要付款 + HTTP_403 = 403 # FORBIDDEN: 禁止访问 + HTTP_404 = 404 # NOT_FOUND: 未找到 + HTTP_405 = 405 # METHOD_NOT_ALLOWED: 方法不允许 + HTTP_406 = 406 # NOT_ACCEPTABLE: 不可接受 + HTTP_407 = 407 # PROXY_AUTHENTICATION_REQUIRED: 需要代理身份验证 + HTTP_408 = 408 # REQUEST_TIMEOUT: 请求超时 + HTTP_409 = 409 # CONFLICT: 冲突 + HTTP_410 = 410 # GONE: 已删除 + HTTP_411 = 411 # LENGTH_REQUIRED: 需要内容长度 + HTTP_412 = 412 # PRECONDITION_FAILED: 先决条件失败 + HTTP_413 = 413 # REQUEST_ENTITY_TOO_LARGE: 请求实体过大 + HTTP_414 = 414 # REQUEST_URI_TOO_LONG: 请求 URI 过长 + HTTP_415 = 415 # UNSUPPORTED_MEDIA_TYPE: 不支持的媒体类型 + HTTP_416 = 416 # REQUESTED_RANGE_NOT_SATISFIABLE: 请求范围不符合要求 + HTTP_417 = 417 # EXPECTATION_FAILED: 期望失败 + HTTP_418 = 418 # UNUSED: 闲置 + HTTP_421 = 421 # MISDIRECTED_REQUEST: 被错导的请求 + HTTP_422 = 422 # UNPROCESSABLE_CONTENT: 无法处理的实体 + HTTP_423 = 423 # LOCKED: 已锁定 + HTTP_424 = 424 # FAILED_DEPENDENCY: 依赖失败 + HTTP_425 = 425 # TOO_EARLY: 太早 + HTTP_426 = 426 # UPGRADE_REQUIRED: 需要升级 + HTTP_427 = 427 # UNASSIGNED: 未分配 + HTTP_428 = 428 # PRECONDITION_REQUIRED: 需要先决条件 + HTTP_429 = 429 # TOO_MANY_REQUESTS: 请求过多 + HTTP_430 = 430 # Unassigned: 未分配 + HTTP_431 = 431 # REQUEST_HEADER_FIELDS_TOO_LARGE: 请求头字段太大 + HTTP_451 = 451 # UNAVAILABLE_FOR_LEGAL_REASONS: 由于法律原因不可用 + HTTP_500 = 500 # INTERNAL_SERVER_ERROR: 服务器内部错误 + HTTP_501 = 501 # NOT_IMPLEMENTED: 未实现 + HTTP_502 = 502 # BAD_GATEWAY: 错误的网关 + HTTP_503 = 503 # SERVICE_UNAVAILABLE: 服务不可用 + HTTP_504 = 504 # GATEWAY_TIMEOUT: 网关超时 + HTTP_505 = 505 # HTTP_VERSION_NOT_SUPPORTED: HTTP 版本不支持 + HTTP_506 = 506 # VARIANT_ALSO_NEGOTIATES: 变体也会协商 + HTTP_507 = 507 # INSUFFICIENT_STORAGE: 存储空间不足 + HTTP_508 = 508 # LOOP_DETECTED: 检测到循环 + HTTP_509 = 509 # UNASSIGNED: 未分配 + HTTP_510 = 510 # NOT_EXTENDED: 未扩展 + HTTP_511 = 511 # NETWORK_AUTHENTICATION_REQUIRED: 需要网络身份验证 + + """ + WebSocket codes + https://www.iana.org/assignments/websocket/websocket.xml#close-code-number + https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent + """ + WS_1000 = 1000 # NORMAL_CLOSURE: 正常闭合 + WS_1001 = 1001 # GOING_AWAY: 正在离开 + WS_1002 = 1002 # PROTOCOL_ERROR: 协议错误 + WS_1003 = 1003 # UNSUPPORTED_DATA: 不支持的数据类型 + WS_1005 = 1005 # NO_STATUS_RCVD: 没有接收到状态 + WS_1006 = 1006 # ABNORMAL_CLOSURE: 异常关闭 + WS_1007 = 1007 # INVALID_FRAME_PAYLOAD_DATA: 无效的帧负载数据 + WS_1008 = 1008 # POLICY_VIOLATION: 策略违规 + WS_1009 = 1009 # MESSAGE_TOO_BIG: 消息太大 + WS_1010 = 1010 # MANDATORY_EXT: 必需的扩展 + WS_1011 = 1011 # INTERNAL_ERROR: 内部错误 + WS_1012 = 1012 # SERVICE_RESTART: 服务重启 + WS_1013 = 1013 # TRY_AGAIN_LATER: 请稍后重试 + WS_1014 = 1014 # BAD_GATEWAY: 错误的网关 + WS_1015 = 1015 # TLS_HANDSHAKE: TLS握手错误 + WS_3000 = 3000 # UNAUTHORIZED: 未经授权 + WS_3003 = 3003 # FORBIDDEN: 禁止访问 diff --git a/backend/common/response/response_schema.py b/backend/common/response/response_schema.py new file mode 100755 index 0000000..bee8463 --- /dev/null +++ b/backend/common/response/response_schema.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Any, Generic, TypeVar + +from fastapi import Response +from pydantic import BaseModel, Field + +from backend.common.response.response_code import CustomResponse, CustomResponseCode +from backend.utils.serializers import MsgSpecJSONResponse + +SchemaT = TypeVar('SchemaT') + + +class ResponseModel(BaseModel): + """ + 不包含返回数据 schema 的通用型统一返回模型 + + 示例:: + + @router.get('/test', response_model=ResponseModel) + def test(): + return ResponseModel(data={'test': 'test'}) + + + @router.get('/test') + def test() -> ResponseModel: + return ResponseModel(data={'test': 'test'}) + + + @router.get('/test') + def test() -> ResponseModel: + res = CustomResponseCode.HTTP_200 + return ResponseModel(code=res.code, msg=res.msg, data={'test': 'test'}) + """ + + code: int = Field(CustomResponseCode.HTTP_200.code, description='返回状态码') + msg: str = Field(CustomResponseCode.HTTP_200.msg, description='返回信息') + data: Any | None = Field(None, description='返回数据') + + +class ResponseSchemaModel(ResponseModel, Generic[SchemaT]): + """ + 包含返回数据 schema 的通用型统一返回模型,仅适用于非分页接口 + + 示例:: + + @router.get('/test', response_model=ResponseSchemaModel[GetApiDetail]) + def test(): + return ResponseSchemaModel[GetApiDetail](data=GetApiDetail(...)) + + + @router.get('/test') + def test() -> ResponseSchemaModel[GetApiDetail]: + return ResponseSchemaModel[GetApiDetail](data=GetApiDetail(...)) + + + @router.get('/test') + def test() -> ResponseSchemaModel[GetApiDetail]: + res = CustomResponseCode.HTTP_200 + return ResponseSchemaModel[GetApiDetail](code=res.code, msg=res.msg, data=GetApiDetail(...)) + """ + + data: SchemaT + + +class ResponseBase: + """统一返回方法""" + + @staticmethod + def __response( + *, res: CustomResponseCode | CustomResponse = None, data: Any | None = None + ) -> ResponseModel | ResponseSchemaModel: + """ + 请求返回通用方法 + + :param res: 返回信息 + :param data: 返回数据 + :return: + """ + return ResponseModel(code=res.code, msg=res.msg, data=data) + + def success( + self, + *, + res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200, + data: Any | None = None, + ) -> ResponseModel | ResponseSchemaModel: + """ + 成功响应 + + :param res: 返回信息 + :param data: 返回数据 + :return: + """ + return self.__response(res=res, data=data) + + def fail( + self, + *, + res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_400, + data: Any = None, + ) -> ResponseModel | ResponseSchemaModel: + """ + 失败响应 + + :param res: 返回信息 + :param data: 返回数据 + :return: + """ + return self.__response(res=res, data=data) + + @staticmethod + def fast_success( + *, + res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200, + data: Any | None = None, + ) -> Response: + """ + 此方法是为了提高接口响应速度而创建的,在解析较大 json 时有显著性能提升,但将丢失 pydantic 解析和验证 + + .. warning:: + + 使用此返回方法时,不能指定接口参数 response_model 和箭头返回类型 + + :param res: 返回信息 + :param data: 返回数据 + :return: + """ + return MsgSpecJSONResponse({'code': res.code, 'msg': res.msg, 'data': data}) + + +response_base: ResponseBase = ResponseBase() diff --git a/backend/common/schema.py b/backend/common/schema.py new file mode 100755 index 0000000..8c9f682 --- /dev/null +++ b/backend/common/schema.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from datetime import datetime +from typing import Annotated, Dict, Any + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, validate_email + +from backend.core.conf import settings + +# 自定义验证错误信息,参考: +# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266 +# https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232 +CUSTOM_VALIDATION_ERROR_MESSAGES = { + 'no_such_attribute': "对象没有属性 '{attribute}'", + 'json_invalid': '无效的 JSON: {error}', + 'json_type': 'JSON 输入应为字符串、字节或字节数组', + 'recursion_loop': '递归错误 - 检测到循环引用', + 'model_type': '输入应为有效的字典或 {class_name} 的实例', + 'model_attributes_type': '输入应为有效的字典或可提取字段的对象', + 'dataclass_exact_type': '输入应为 {class_name} 的实例', + 'dataclass_type': '输入应为字典或 {class_name} 的实例', + 'missing': '字段为必填项', + 'frozen_field': '字段已冻结', + 'frozen_instance': '实例已冻结', + 'extra_forbidden': '不允许额外的输入', + 'invalid_key': '键应为字符串', + 'get_attribute_error': '提取属性时出错: {error}', + 'none_required': '输入应为 None', + 'enum': '输入应为 {expected}', + 'greater_than': '输入应大于 {gt}', + 'greater_than_equal': '输入应大于或等于 {ge}', + 'less_than': '输入应小于 {lt}', + 'less_than_equal': '输入应小于或等于 {le}', + 'finite_number': '输入应为有限数字', + 'too_short': '{field_type} 在验证后应至少有 {min_length} 个项目,而不是 {actual_length}', + 'too_long': '{field_type} 在验证后最多应有 {max_length} 个项目,而不是 {actual_length}', + 'string_type': '输入应为有效的字符串', + 'string_sub_type': '输入应为字符串,而不是 str 子类的实例', + 'string_unicode': '输入应为有效的字符串,无法将原始数据解析为 Unicode 字符串', + 'string_pattern_mismatch': "字符串应匹配模式 '{pattern}'", + 'string_too_short': '字符串应至少有 {min_length} 个字符', + 'string_too_long': '字符串最多应有 {max_length} 个字符', + 'dict_type': '输入应为有效的字典', + 'mapping_type': '输入应为有效的映射,错误: {error}', + 'iterable_type': '输入应为可迭代对象', + 'iteration_error': '迭代对象时出错,错误: {error}', + 'list_type': '输入应为有效的列表', + 'tuple_type': '输入应为有效的元组', + 'set_type': '输入应为有效的集合', + 'bool_type': '输入应为有效的布尔值', + 'bool_parsing': '输入应为有效的布尔值,无法解释输入', + 'int_type': '输入应为有效的整数', + 'int_parsing': '输入应为有效的整数,无法将字符串解析为整数', + 'int_parsing_size': '无法将输入字符串解析为整数,超出最大大小', + 'int_from_float': '输入应为有效的整数,得到一个带有小数部分的数字', + 'multiple_of': '输入应为 {multiple_of} 的倍数', + 'float_type': '输入应为有效的数字', + 'float_parsing': '输入应为有效的数字,无法将字符串解析为数字', + 'bytes_type': '输入应为有效的字节', + 'bytes_too_short': '数据应至少有 {min_length} 个字节', + 'bytes_too_long': '数据最多应有 {max_length} 个字节', + 'value_error': '值错误,{error}', + 'assertion_error': '断言失败,{error}', + 'literal_error': '输入应为 {expected}', + 'date_type': '输入应为有效的日期', + 'date_parsing': '输入应为 YYYY-MM-DD 格式的有效日期,{error}', + 'date_from_datetime_parsing': '输入应为有效的日期或日期时间,{error}', + 'date_from_datetime_inexact': '提供给日期的日期时间应具有零时间 - 例如为精确日期', + 'date_past': '日期应为过去的时间', + 'date_future': '日期应为未来的时间', + 'time_type': '输入应为有效的时间', + 'time_parsing': '输入应为有效的时间格式,{error}', + 'datetime_type': '输入应为有效的日期时间', + 'datetime_parsing': '输入应为有效的日期时间,{error}', + 'datetime_object_invalid': '无效的日期时间对象,得到 {error}', + 'datetime_past': '输入应为过去的时间', + 'datetime_future': '输入应为未来的时间', + 'timezone_naive': '输入不应包含时区信息', + 'timezone_aware': '输入应包含时区信息', + 'timezone_offset': '需要时区偏移为 {tz_expected},实际得到 {tz_actual}', + 'time_delta_type': '输入应为有效的时间差', + 'time_delta_parsing': '输入应为有效的时间差,{error}', + 'frozen_set_type': '输入应为有效的冻结集合', + 'is_instance_of': '输入应为 {class} 的实例', + 'is_subclass_of': '输入应为 {class} 的子类', + 'callable_type': '输入应为可调用对象', + 'union_tag_invalid': "使用 {discriminator} 找到的输入标签 '{tag}' 与任何预期标签不匹配: {expected_tags}", + 'union_tag_not_found': '无法使用区分器 {discriminator} 提取标签', + 'arguments_type': '参数必须是元组、列表或字典', + 'missing_argument': '缺少必需参数', + 'unexpected_keyword_argument': '意外的关键字参数', + 'missing_keyword_only_argument': '缺少必需的关键字专用参数', + 'unexpected_positional_argument': '意外的位置参数', + 'missing_positional_only_argument': '缺少必需的位置专用参数', + 'multiple_argument_values': '为参数提供了多个值', + 'url_type': 'URL 输入应为字符串或 URL', + 'url_parsing': '输入应为有效的 URL,{error}', + 'url_syntax_violation': '输入违反了严格的 URL 语法规则,{error}', + 'url_too_long': 'URL 最多应有 {max_length} 个字符', + 'url_scheme': 'URL 方案应为 {expected_schemes}', + 'uuid_type': 'UUID 输入应为字符串、字节或 UUID 对象', + 'uuid_parsing': '输入应为有效的 UUID,{error}', + 'uuid_version': '预期 UUID 版本为 {expected_version}', + 'decimal_type': '十进制输入应为整数、浮点数、字符串或 Decimal 对象', + 'decimal_parsing': '输入应为有效的十进制数', + 'decimal_max_digits': '十进制输入总共应不超过 {max_digits} 位数字', + 'decimal_max_places': '十进制输入应不超过 {decimal_places} 位小数', + 'decimal_whole_digits': '十进制输入在小数点前应不超过 {whole_digits} 位数字', +} + +CustomPhoneNumber = Annotated[str, Field(pattern=r'^1[3-9]\d{9}$')] + + +class CustomEmailStr(EmailStr): + """自定义邮箱类型""" + + @classmethod + def _validate(cls, __input_value: str) -> str: + return None if __input_value == '' else validate_email(__input_value)[1] + + +class SchemaBase(BaseModel): + """基础模型配置""" + + model_config = ConfigDict( + use_enum_values=True, + json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT), + int: lambda v: str(v) if v > 2**53 - 1 else v + }, + ) + + def dict(self, *args, **kwargs) -> Dict[str, Any]: + """重载 dict 方法处理大整数""" + d = super().dict(*args, **kwargs) + for key, value in d.items(): + if isinstance(value, int) and value > 2 ** 53 - 1: + d[key] = str(value) + return d diff --git a/backend/common/security/__init__.py b/backend/common/security/__init__.py new file mode 100755 index 0000000..6f7b18c --- /dev/null +++ b/backend/common/security/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/common/security/jwt.py b/backend/common/security/jwt.py new file mode 100755 index 0000000..f51f9e9 --- /dev/null +++ b/backend/common/security/jwt.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import json + +from datetime import timedelta +from typing import Any +from uuid import uuid4 + +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPBearer +from fastapi.security.http import HTTPAuthorizationCredentials +from fastapi.security.utils import get_authorization_scheme_param +from jose import ExpiredSignatureError, JWTError, jwt +from pwdlib import PasswordHash +from pwdlib.hashers.bcrypt import BcryptHasher +from pydantic_core import from_json +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.admin.model import WxUser +from backend.app.admin.schema.wx import GetWxUserInfoWithRelationDetail +from backend.common.dataclasses import AccessToken, NewToken, RefreshToken, TokenPayload +from backend.common.exception import errors +from backend.common.exception.errors import TokenError +from backend.core.conf import settings +from backend.database.db import async_db_session +from backend.database.redis import redis_client +from backend.utils.serializers import select_as_dict +from backend.utils.timezone import timezone + + +class CustomHTTPBearer(HTTPBearer): + """ + 自定义 HTTPBearer 认证类 + """ + + def __init__(self, auto_error: bool = False): + super().__init__(auto_error=auto_error) + + async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None: + + try: + credentials: HTTPAuthorizationCredentials = await super().__call__(request) + + if credentials: + token = credentials.credentials + # 验证 token 是否有效 + if self._is_valid_jwt(token): + return credentials + + # Header 失败,尝试从 Cookie 获取 + cookie_token = request.cookies.get("access_token") + if cookie_token and self._is_valid_jwt(cookie_token): + # 构造一个 fake credentials 对象 + return HTTPAuthorizationCredentials(scheme="Bearer", credentials=cookie_token) + + # 都失败 + if self.auto_error: + raise TokenError() + except HTTPException as e: + if e.status_code == 403: + raise TokenError() + raise e + + + def _is_valid_jwt(self, token: str) -> bool: + try: + # 这里用你的 SECRET_KEY 和 ALGORITHM + jwt.decode(token, settings.TOKEN_SECRET_KEY, algorithms=[settings.TOKEN_ALGORITHM]) + return True + except JWTError: + return False + + +# JWT authorizes dependency injection +DependsJwtAuth = Depends(CustomHTTPBearer()) + +password_hash = PasswordHash((BcryptHasher(),)) + + +def get_hash_password(password: str, salt: bytes | None) -> str: + """ + 使用哈希算法加密密码 + + :param password: 密码 + :param salt: 盐值 + :return: + """ + return password_hash.hash(password, salt=salt) + + +def password_verify(plain_password: str, hashed_password: str) -> bool: + """ + 密码验证 + + :param plain_password: 待验证的密码 + :param hashed_password: 哈希密码 + :return: + """ + return password_hash.verify(plain_password, hashed_password) + + +def jwt_encode(payload: dict[str, Any]) -> str: + """ + 生成 JWT token + + :param payload: 载荷 + :return: + """ + return jwt.encode(payload, settings.TOKEN_SECRET_KEY, settings.TOKEN_ALGORITHM) + + +def jwt_decode(token: str) -> TokenPayload: + """ + 解析 JWT token + + :param token: JWT token + :return: + """ + try: + payload = jwt.decode( + token, + settings.TOKEN_SECRET_KEY, + algorithms=[settings.TOKEN_ALGORITHM], + options={'verify_exp': True}, + ) + session_uuid = payload.get('session_uuid') + user_id = payload.get('sub') + expire = payload.get('exp') + if not session_uuid or not user_id or not expire: + raise errors.TokenError(msg='Token 无效') + except ExpiredSignatureError: + raise errors.TokenError(msg='Token 已过期') + except (JWTError, Exception): + raise errors.TokenError(msg='Token 无效') + return TokenPayload( + id=int(user_id), session_uuid=session_uuid, expire_time=timezone.from_datetime(timezone.to_utc(expire)) + ) + + +async def create_access_token(user_id: int, multi_login: bool, **kwargs) -> AccessToken: + """ + 生成加密 token + + :param user_id: 用户 ID + :param multi_login: 是否允许多端登录 + :param kwargs: token 额外信息 + :return: + """ + expire = timezone.now() + timedelta(seconds=settings.TOKEN_EXPIRE_SECONDS) + session_uuid = str(uuid4()) + access_token = jwt_encode({ + 'session_uuid': session_uuid, + 'exp': timezone.to_utc(expire).timestamp(), + 'sub': str(user_id), + }) + + if not multi_login: + await redis_client.delete_prefix(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}') + + await redis_client.setex( + f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}', + settings.TOKEN_EXPIRE_SECONDS, + access_token, + ) + + # Token 附加信息单独存储 + if kwargs: + await redis_client.setex( + f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}', + settings.TOKEN_EXPIRE_SECONDS, + json.dumps(kwargs, ensure_ascii=False), + ) + + return AccessToken(access_token=access_token, access_token_expire_time=expire, session_uuid=session_uuid) + + +async def create_refresh_token(session_uuid: str, user_id: int, multi_login: bool) -> RefreshToken: + """ + 生成加密刷新 token,仅用于创建新的 token + + :param session_uuid: 会话 UUID + :param user_id: 用户 ID + :param multi_login: 是否允许多端登录 + :return: + """ + expire = timezone.now() + timedelta(seconds=settings.TOKEN_REFRESH_EXPIRE_SECONDS) + refresh_token = jwt_encode({ + 'session_uuid': session_uuid, + 'exp': timezone.to_utc(expire).timestamp(), + 'sub': str(user_id), + }) + + if not multi_login: + await redis_client.delete_prefix(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}') + + await redis_client.setex( + f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}', + settings.TOKEN_REFRESH_EXPIRE_SECONDS, + refresh_token, + ) + return RefreshToken(refresh_token=refresh_token, refresh_token_expire_time=expire) + + +async def create_new_token( + refresh_token: str, session_uuid: str, user_id: int, multi_login: bool, **kwargs +) -> NewToken: + """ + 生成新的 token + + :param refresh_token: 刷新 token + :param session_uuid: 会话 UUID + :param user_id: 用户 ID + :param multi_login: 是否允许多端登录 + :param kwargs: token 附加信息 + :return: + """ + redis_refresh_token = await redis_client.get(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}') + if not redis_refresh_token or redis_refresh_token != refresh_token: + raise errors.TokenError(msg='Refresh Token 已过期,请重新登录') + + await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{session_uuid}') + await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}') + + new_access_token = await create_access_token(user_id, multi_login, **kwargs) + new_refresh_token = await create_refresh_token(new_access_token.session_uuid, user_id, multi_login) + return NewToken( + new_access_token=new_access_token.access_token, + new_access_token_expire_time=new_access_token.access_token_expire_time, + new_refresh_token=new_refresh_token.refresh_token, + new_refresh_token_expire_time=new_refresh_token.refresh_token_expire_time, + session_uuid=new_access_token.session_uuid, + ) + + +async def revoke_token(user_id: int, session_uuid: str) -> None: + """ + 撤销 token + + :param user_id: 用户 ID + :param session_uuid: 会话 ID + :return: + """ + await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{session_uuid}') + await redis_client.delete(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{user_id}:{session_uuid}') + + +def get_token(request: Request) -> str: + """ + 获取请求头中的 token + + :param request: FastAPI 请求对象 + :return: + """ + authorization = request.headers.get('Authorization') + scheme, token = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != 'bearer': + raise errors.TokenError(msg='Token 无效') + return token + + +async def get_current_wx_user(db: AsyncSession, pk: int) -> WxUser: + """ + 获取当前用户 + + :param db: 数据库会话 + :param pk: 用户 ID + :return: + """ + from backend.app.admin.crud.wx_user_crud import wx_user_dao + + user = await wx_user_dao.get_with_relation(db, user_id=pk) + if not user: + raise errors.TokenError(msg='Token 无效') + # if not user.status: + # raise errors.AuthorizationError(msg='用户已被锁定,请联系系统管理员') + # if user.dept_id: + # if not user.dept.status: + # raise errors.AuthorizationError(msg='用户所属部门已被锁定,请联系系统管理员') + # if user.dept.del_flag: + # raise errors.AuthorizationError(msg='用户所属部门已被删除,请联系系统管理员') + # if user.roles: + # role_status = [role.status for role in user.roles] + # if all(status == 0 for status in role_status): + # raise errors.AuthorizationError(msg='用户所属角色已被锁定,请联系系统管理员') + return user + + +def superuser_verify(request: Request) -> bool: + """ + 验证当前用户权限 + + :param request: FastAPI 请求对象 + :return: + """ + superuser = request.user.is_superuser + if not superuser or not request.user.is_staff: + raise errors.AuthorizationError() + return superuser + + +async def jwt_authentication(token: str) -> GetWxUserInfoWithRelationDetail: + """ + JWT 认证 + + :param token: JWT token + :return: + """ + token_payload = jwt_decode(token) + user_id = token_payload.id + redis_token = await redis_client.get(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}') + if not redis_token: + raise errors.TokenError(msg='Token 已过期') + + if token != redis_token: + raise errors.TokenError(msg='Token 已失效') + + cache_user = await redis_client.get(f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}') + if not cache_user: + async with async_db_session() as db: + current_user = await get_current_wx_user(db, user_id) + user = GetWxUserInfoWithRelationDetail(**select_as_dict(current_user)) + await redis_client.setex( + f'{settings.JWT_USER_REDIS_PREFIX}:{user_id}', + settings.JWT_USER_REDIS_EXPIRE_SECONDS, + user.model_dump_json(), + ) + else: + # TODO: 在恰当的时机,应替换为使用 model_validate_json + # https://docs.pydantic.dev/latest/concepts/json/#partial-json-parsing + user = GetWxUserInfoWithRelationDetail.model_validate(from_json(cache_user, allow_partial=True)) + return user diff --git a/backend/common/security/wx_security.py b/backend/common/security/wx_security.py new file mode 100755 index 0000000..fceaa0d --- /dev/null +++ b/backend/common/security/wx_security.py @@ -0,0 +1,80 @@ +import base64 +import hashlib +import hmac +import json +import os +from typing import Tuple + +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.asymmetric import padding as asym_padding +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.backends import default_backend + + +class SecurityService: + def __init__(self): + # 初始化时生成或加载RSA密钥对 + self.private_key, self.public_key = self._generate_rsa_keypair() + + def _generate_rsa_keypair(self) -> Tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]: + """生成RSA密钥对""" + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + public_key = private_key.public_key() + return private_key, public_key + + def get_public_key_pem(self) -> str: + """获取PEM格式的公钥""" + return self.public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ).decode('utf-8') + + def rsa_decrypt(self, encrypted_data: bytes) -> bytes: + """RSA解密""" + return self.private_key.decrypt( + encrypted_data, + asym_padding.OAEP( + mgf=asym_padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None + ) + ) + + def aes_encrypt(self, key: bytes, iv: bytes, data: bytes) -> bytes: + """AES-256-CBC加密""" + padder = padding.PKCS7(128).padder() + padded_data = padder.update(data) + padder.finalize() + + cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) + encryptor = cipher.encryptor() + return encryptor.update(padded_data) + encryptor.finalize() + + def aes_decrypt(self, key: bytes, iv: bytes, encrypted_data: bytes) -> bytes: + """AES-256-CBC解密""" + cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) + decryptor = cipher.decryptor() + decrypted_padded = decryptor.update(encrypted_data) + decryptor.finalize() + + unpadder = padding.PKCS7(128).unpadder() + return unpadder.update(decrypted_padded) + unpadder.finalize() + + def generate_signature(self, data: dict, session_key: str) -> str: + """生成请求签名 (HMAC-SHA256)""" + sorted_data = "&".join([f"{k}={v}" for k, v in sorted(data.items())]) + signature = hmac.new( + session_key.encode('utf-8'), + sorted_data.encode('utf-8'), + digestmod=hashlib.sha256 + ).digest() + return base64.b64encode(signature).decode('utf-8') + + def verify_signature(self, data: dict, signature: str, session_key: str) -> bool: + """验证请求签名""" + expected_signature = self.generate_signature(data, session_key) + return hmac.compare_digest(expected_signature, signature) \ No newline at end of file diff --git a/backend/common/socketio/__init__.py b/backend/common/socketio/__init__.py new file mode 100755 index 0000000..f6eb45c --- /dev/null +++ b/backend/common/socketio/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from .actions import * # noqa: F403 diff --git a/backend/common/socketio/actions.py b/backend/common/socketio/actions.py new file mode 100755 index 0000000..8abc256 --- /dev/null +++ b/backend/common/socketio/actions.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from backend.common.socketio.server import sio + + +async def task_notification(msg: str): + """ + 任务通知 + + :param msg: 通知信息 + :return: + """ + await sio.emit('task_notification', {'msg': msg}) diff --git a/backend/common/socketio/server.py b/backend/common/socketio/server.py new file mode 100755 index 0000000..6edda81 --- /dev/null +++ b/backend/common/socketio/server.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import socketio + +from backend.common.log import log +from backend.common.security.jwt import jwt_authentication +from backend.core.conf import settings +from backend.database.redis import redis_client + +# 创建 Socket.IO 服务器实例 +sio = socketio.AsyncServer( + client_manager=socketio.AsyncRedisManager( + f'redis://:{settings.REDIS_PASSWORD}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DATABASE}' + ), + async_mode='asgi', + cors_allowed_origins=settings.CORS_ALLOWED_ORIGINS, + cors_credentials=True, + namespaces=['/ws'], +) + + +@sio.event +async def connect(sid, environ, auth): + """Socket 连接事件""" + if not auth: + log.error('WebSocket 连接失败:无授权') + return False + + session_uuid = auth.get('session_uuid') + token = auth.get('token') + if not token or not session_uuid: + log.error('WebSocket 连接失败:授权失败,请检查') + return False + + # 免授权直连 + if token == settings.WS_NO_AUTH_MARKER: + await redis_client.sadd(settings.TOKEN_ONLINE_REDIS_PREFIX, session_uuid) + return True + + try: + await jwt_authentication(token) + except Exception as e: + log.info(f'WebSocket 连接失败:{str(e)}') + return False + + await redis_client.sadd(settings.TOKEN_ONLINE_REDIS_PREFIX, session_uuid) + return True + + +@sio.event +async def disconnect(sid) -> None: + """Socket 断开连接事件""" + await redis_client.spop(settings.TOKEN_ONLINE_REDIS_PREFIX) diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100755 index 0000000..6f7b18c --- /dev/null +++ b/backend/core/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/core/conf.py b/backend/core/conf.py new file mode 100755 index 0000000..2deabaa --- /dev/null +++ b/backend/core/conf.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from functools import lru_cache +from typing import Any, Literal +from celery.schedules import crontab +from pydantic import model_validator, PostgresDsn +from pydantic_settings import BaseSettings, SettingsConfigDict + +from backend.core.path_conf import BASE_PATH + + +class Settings(BaseSettings): + """全局配置""" + + model_config = SettingsConfigDict( + env_file=f'{BASE_PATH}/.env', + env_file_encoding='utf-8', + extra='ignore', + case_sensitive=True, + ) + + # .env 环境 + ENVIRONMENT: Literal['dev', 'pro'] + + # server + SERVER_HOST: str + SERVER_PORT: int + + # 微信 Token 配置 + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天 + REFRESH_TOKEN_EXPIRE_DAYS: int = 30 + MAX_REFRESH_ATTEMPTS: int = 3 # 最大刷新尝试次数 + # 安全配置 + TOKEN_REFRESH_ENABLED: bool = True + AUTO_RETRY_METHODS: list = ["GET", "HEAD", "OPTIONS"] + # 微信配置 + WX_APPID: str + WX_SECRET: str + WX_MCH_ID: str = "" + WX_PAY_CERT_PATH: str = "" + WX_PAY_KEY_PATH: str = "" + + # model key + QWEN_API_KEY: str + QWEN_VISION_MODEL: str + QWEN_VISION_EMBEDDING_MODEL: str + API_TIMEOUT: int = 600 + ASYNC_POLL_INTERVAL: int = 1 + ASYNC_MODE: str = "enable" + TENCENT_CLOUD_APP_ID: str + TENCENT_CLOUD_SECRET_ID: str + TENCENT_CLOUD_SECRET_KEY: str + TENCENT_CLOUD_ENGINE_MODEL_TYPE: str = "16k_en" + + # .env 数据库 + DATABASE_ECHO: bool | Literal['debug'] = False + DATABASE_HOST: str + DATABASE_PORT: int = 5432 + DATABASE_USER: str + DATABASE_PASSWORD: str + DATABASE_DB_NAME: str = 'postgres' + + # .env Redis + REDIS_HOST: str + REDIS_PORT: int + REDIS_PASSWORD: str + REDIS_DATABASE: int + + # .env Token + TOKEN_SECRET_KEY: str # 密钥 secrets.token_urlsafe(32) + + # FastAPI + FASTAPI_API_V1_PATH: str = '/api/v1' + FASTAPI_TITLE: str = 'FastAPI' + FASTAPI_VERSION: str = '0.0.1' + FASTAPI_DESCRIPTION: str = 'FastAPI Best Architecture' + FASTAPI_DOCS_URL: str = '/docs' + FASTAPI_REDOC_URL: str = '/redoc' + FASTAPI_OPENAPI_URL: str | None = '/openapi' + FASTAPI_STATIC_FILES: bool = False + + # Redis + REDIS_TIMEOUT: int = 10 + + # Token + TOKEN_URL_SWAGGER: str = f'{FASTAPI_API_V1_PATH}/auth/login/swagger' + TOKEN_ALGORITHM: str = 'HS256' + TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天 + TOKEN_REFRESH_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天 + TOKEN_REDIS_PREFIX: str = 'app:token' + TOKEN_EXTRA_INFO_REDIS_PREFIX: str = 'app:token_extra_info' + TOKEN_ONLINE_REDIS_PREFIX: str = 'app:token_online' + TOKEN_REFRESH_REDIS_PREFIX: str = 'app:refresh_token' + TOKEN_REQUEST_PATH_EXCLUDE: list[str] = [ # JWT / RBAC 路由白名单 + f'{FASTAPI_API_V1_PATH}/wx/login', + f'{FASTAPI_API_V1_PATH}/auth/login', + f'{FASTAPI_API_V1_PATH}/auth/logout', + ] + + # JWT + JWT_USER_REDIS_PREFIX: str = 'app:user' + JWT_USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天 + + # Cookie + COOKIE_REFRESH_TOKEN_KEY: str = 'app_refresh_token' + COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天 + + # 日志 + LOG_CID_DEFAULT_VALUE: str = '-' + LOG_CID_UUID_LENGTH: int = 32 # 日志 correlation_id 长度,必须小于等于 32 + LOG_STD_LEVEL: str = 'INFO' + LOG_ACCESS_FILE_LEVEL: str = 'INFO' + LOG_ERROR_FILE_LEVEL: str = 'ERROR' + LOG_STD_FORMAT: str = ( + '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | ' + ' {correlation_id} | {message}' + ) + LOG_FILE_FORMAT: str = ( + '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | ' + ' {correlation_id} | {message}' + ) + LOG_ACCESS_FILENAME: str = 'app_access.log' + LOG_ERROR_FILENAME: str = 'app_error.log' + + # CORS + CORS_ALLOWED_ORIGINS: list[str] = [ + 'http://127.0.0.1:8000', + ] + CORS_EXPOSE_HEADERS: list[str] = [ + '*', + ] + + # 文件上传 + UPLOAD_READ_SIZE: int = 1024 + UPLOAD_IMAGE_EXT_INCLUDE: list[str] = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'mp3', 'wav'] + UPLOAD_IMAGE_SIZE_MAX: int = 5 * 1024 * 1024 # 5 MB + UPLOAD_VIDEO_EXT_INCLUDE: list[str] = ['mp4', 'mov', 'avi', 'flv'] + UPLOAD_VIDEO_SIZE_MAX: int = 20 * 1024 * 1024 # 20 MB + UPLOAD_FILE_SIZE_MAX: int = 50 * 1024 * 1024 # 50 MB + + # 文件存储配置 + DEFAULT_STORAGE_TYPE: str = "database" # database, local, s3 + + # Captcha + CAPTCHA_LOGIN_REDIS_PREFIX: str = 'app:login:captcha' + CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 过期时间,单位:秒 + + # 中间件 + MIDDLEWARE_CORS: bool = True + MIDDLEWARE_ACCESS: bool = True + + # DateTime + DATETIME_TIMEZONE: str = 'Asia/Shanghai' + DATETIME_FORMAT: str = '%Y-%m-%d %H:%M:%S' + + # Request limiter + REQUEST_LIMITER_REDIS_PREFIX: str = 'app:limiter' + + # tmp folder + STORAGE_PATH: str = '/tmp' + + # Demo mode (Only GET, OPTIONS requests are allowed) + DEMO_MODE: bool = False + DEMO_MODE_EXCLUDE: set[tuple[str, str]] = { + ('POST', f'{FASTAPI_API_V1_PATH}/auth/login'), + ('POST', f'{FASTAPI_API_V1_PATH}/auth/logout'), + ('GET', f'{FASTAPI_API_V1_PATH}/auth/captcha'), + } + + # 基础配置 + CELERY_BROKER: Literal['redis'] = 'redis' + CELERY_REDIS_PREFIX: str = 'fba:celery' + CELERY_TASK_MAX_RETRIES: int = 5 + + CELERY_BROKER_REDIS_DATABASE: int = 1 + + @model_validator(mode='before') + @classmethod + def validator_api_url(cls, values): + if values['ENVIRONMENT'] == 'pro': + values['FASTAPI_OPENAPI_URL'] = None + values['FASTAPI_STATIC_FILES'] = False + return values + + +@lru_cache +def get_settings(): + """读取配置优化写法""" + return Settings() + + +# 环境区分示例 +def get_db_uri(settings: Settings): + return PostgresDsn.build( + scheme="postgresql+asyncpg", + username=settings.DATABASE_USER, + password=settings.DATABASE_PASSWORD, + host=settings.DATABASE_HOST, + port=settings.DATABASE_PORT, + path=settings.DATABASE_DB_NAME, + ).unicode_string() + + +settings = get_settings() diff --git a/backend/core/database.py b/backend/core/database.py new file mode 100755 index 0000000..3fcbcb8 --- /dev/null +++ b/backend/core/database.py @@ -0,0 +1,51 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, scoped_session + +from backend.core.conf import get_db_uri, settings + +# 主引擎(用于常规请求) +engine = create_engine( + get_db_uri(settings), + pool_size=20, + max_overflow=10, + pool_pre_ping=True +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +async_engine = create_async_engine( + get_db_uri(settings), # 注意 asyncpg 驱动 + echo=True, # 可选,用于调试 + future=True # 使用2.0风格API +) + +AsyncSessionLocal = sessionmaker( + bind=engine, + class_=AsyncSession, # 关键:指定使用异步Session类 + autocommit=False, + autoflush=False, + expire_on_commit=False # 推荐在异步环境中设置 +) + +# 独立引擎(用于中间件等需要独立连接的场景) +independence_engine = create_engine( + get_db_uri(settings), + pool_size=5, + pool_pre_ping=True +) +IndependenceSession = sessionmaker(autocommit=False, autoflush=False, bind=independence_engine) + +Base = declarative_base() + +def get_db(): + """依赖项:获取数据库会话""" + db = SessionLocal() + try: + yield db + finally: + db.close() + +async def get_async_db() -> AsyncSession: + async with AsyncSessionLocal() as session: + yield session \ No newline at end of file diff --git a/backend/core/path_conf.py b/backend/core/path_conf.py new file mode 100755 index 0000000..a274fef --- /dev/null +++ b/backend/core/path_conf.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from pathlib import Path + +# 项目根目录 +BASE_PATH = Path(__file__).resolve().parent.parent + +# alembic 迁移文件存放路径 +ALEMBIC_VERSION_DIR = BASE_PATH / 'alembic' / 'versions' + +# 日志文件路径 +LOG_DIR = BASE_PATH / 'log' + +# 静态资源目录 +STATIC_DIR = BASE_PATH / 'static' + +# 上传文件目录 +UPLOAD_DIR = STATIC_DIR / 'upload' + diff --git a/backend/core/registrar.py b/backend/core/registrar.py new file mode 100755 index 0000000..964d64b --- /dev/null +++ b/backend/core/registrar.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import os.path +from contextlib import asynccontextmanager + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from asgi_correlation_id import CorrelationIdMiddleware +from fastapi import FastAPI, Depends +from fastapi_limiter import FastAPILimiter +from fastapi_pagination import add_pagination +from starlette.middleware.authentication import AuthenticationMiddleware + +from backend.app.admin.tasks import wx_user_index_history +from backend.app.router import router +from backend.common.exception.exception_handler import register_exception +from backend.common.log import setup_logging, set_custom_logfile +from backend.core.path_conf import STATIC_DIR +from backend.database.redis import redis_client +from backend.core.conf import settings +from backend.database.db import create_table +from backend.middleware.id_conversion import IDConversionMiddleware +from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware +from backend.utils.demo_site import demo_site +from backend.utils.health_check import http_limit_callback, ensure_unique_route_names +from backend.utils.openapi import simplify_operation_ids + + +@asynccontextmanager +async def register_init(app: FastAPI): + """ + 启动初始化 + + :return: + """ + # 创建数据库表 + await create_table() + # 连接 redis + await redis_client.open() + # 初始化 limiter + await FastAPILimiter.init( + redis_client, + prefix=settings.REQUEST_LIMITER_REDIS_PREFIX, + http_callback=http_limit_callback, + ) + + # 定时任务 + scheduler = AsyncIOScheduler() + scheduler.add_job(wx_user_index_history, "cron", hour=00, minute=1) + scheduler.start() + + yield + + # 关闭 redis 连接 + await redis_client.close() + # 关闭 limiter + await FastAPILimiter.close() + # 关闭定时任务 + scheduler.shutdown() + + +def register_app(): + # FastAPI + app = FastAPI( + title=settings.FASTAPI_TITLE, + version=settings.FASTAPI_VERSION, + description=settings.FASTAPI_DESCRIPTION, + docs_url=settings.FASTAPI_DOCS_URL, + redoc_url=settings.FASTAPI_REDOC_URL, + openapi_url=settings.FASTAPI_OPENAPI_URL, + lifespan=register_init, + ) + + # 注册组件 + register_logger() + register_static_file(app) + register_middleware(app) + register_router(app) + register_page(app) + register_exception(app) + + return app + + +def register_logger() -> None: + """ + 系统日志 + + :return: + """ + setup_logging() + set_custom_logfile() + + +def register_static_file(app: FastAPI): + """ + 静态文件交互开发模式, 生产将自动关闭,生产必须使用 nginx 静态资源服务 + + :param app: + :return: + """ + if settings.FASTAPI_STATIC_FILES: + from fastapi.staticfiles import StaticFiles + + if not os.path.exists(STATIC_DIR): + os.makedirs(STATIC_DIR) + + app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static') + + +def register_middleware(app) -> None: + + # JWT auth (必须) + app.add_middleware( + AuthenticationMiddleware, + backend=JwtAuthMiddleware(), + on_error=JwtAuthMiddleware.auth_exception_handler, + ) + # 接口访问日志 + if settings.MIDDLEWARE_ACCESS: + from backend.middleware.access_middle import AccessMiddleware + + app.add_middleware(AccessMiddleware) + + # Trace ID (必须) + app.add_middleware(CorrelationIdMiddleware, validator=False) + + # 将 id 从 str 转为 int + app.add_middleware(IDConversionMiddleware) + + # 跨域 + if settings.MIDDLEWARE_CORS: + from starlette.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=['*'], + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], + ) + + # app.add_middleware( + # TokenRefreshMiddleware, + # excluded_paths=[ + # "/wx_auth/login", + # "/wx_auth/refresh", + # "/docs", + # "/openapi.json" + # ] + # ) + + +def register_router(app: FastAPI): + """ + 路由 + + :param app: FastAPI + :return: + """ + dependencies = [Depends(demo_site)] if settings.DEMO_MODE else None + + # API + app.include_router(router, dependencies=dependencies) + + # Extra + ensure_unique_route_names(app) + simplify_operation_ids(app) + + +def register_page(app: FastAPI): + """ + 分页查询 + + :param app: + :return: + """ + add_pagination(app) diff --git a/backend/core/wx_integration.py b/backend/core/wx_integration.py new file mode 100755 index 0000000..34e5372 --- /dev/null +++ b/backend/core/wx_integration.py @@ -0,0 +1,61 @@ +# core/wx_integration.py +import httpx +import logging +from backend.core.conf import settings +from fastapi import HTTPException + + +async def verify_wx_code(code: str): + """ + 使用code向微信服务器验证并获取session_key和openid + """ + # settings = get_settings() + url = f"https://api.weixin.qq.com/sns/jscode2session?appid={settings.WX_APPID}&secret={settings.WX_SECRET}&js_code={code}&grant_type=authorization_code" + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url) + result = response.json() + + if "errcode" in result: + logging.error(f"微信认证失败: {result.get('errmsg', '未知错误')}") + return None + + return { + "openid": result["openid"], + "session_key": result["session_key"], + "unionid": result.get("unionid", "") + } + except Exception as e: + logging.error(f"微信API请求失败: {str(e)}") + raise HTTPException(status_code=503, detail="微信服务不可用") + + +def decrypt_wx_data(encrypted_data: str, session_key: str, iv: str): + """ + 解密微信加密数据 + """ + from Crypto.Cipher import AES + import base64 + import json + + try: + # Base64解码 + encrypted_bytes = base64.b64decode(encrypted_data) + session_key_bytes = base64.b64decode(session_key) + iv_bytes = base64.b64decode(iv) + + # AES解密 + cipher = AES.new(session_key_bytes, AES.MODE_CBC, iv_bytes) + decrypted_bytes = cipher.decrypt(encrypted_bytes) + + # 去除填充 + pad = decrypted_bytes[-1] + decrypted_bytes = decrypted_bytes[:-pad] + + # 转换为JSON + decrypted_str = decrypted_bytes.decode('utf-8') + return json.loads(decrypted_str) + except Exception as e: + logging.error(f"数据解密失败: {str(e)}") + raise ValueError("数据解密失败") \ No newline at end of file diff --git a/backend/database/__init__.py b/backend/database/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/backend/database/db.py b/backend/database/db.py new file mode 100755 index 0000000..9c6c431 --- /dev/null +++ b/backend/database/db.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import sys + +from typing import Annotated +from uuid import uuid4 + +from fastapi import Depends +from sqlalchemy import URL, text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine, AsyncEngine +from sqlalchemy.orm import Session + +from backend.common.log import log +from backend.common.model import MappedBase +from backend.core.conf import settings, get_db_uri + + +def create_async_engine_and_session( + url: str | URL, + echo: bool = False, + pool_size: int = 10, + max_overflow: int = 20, + pool_timeout: int = 30, + pool_recycle: int = 3600, + pool_pre_ping: bool = True, + application_name: str = "app" +) -> tuple[create_async_engine, async_sessionmaker[AsyncSession]]: + """ + 创建 PostgreSQL 异步引擎和会话工厂 + 参数优化说明: + - pool_size: 建议设置为 (核心数 * 2) + 有效磁盘数 + - max_overflow: 峰值连接缓冲,避免连接风暴 + - pool_recycle: 防止 PostgreSQL 连接超时 (默认为 1 小时) + - pool_pre_ping: 强烈建议开启,处理连接失效问题 + - application_name: 帮助 DBA 识别连接来源 + """ + + try: + # 创建异步引擎 (针对 PostgreSQL 优化) + engine = create_async_engine( + url, + echo=echo, + echo_pool=echo, + future=True, + connect_args={ + "server_settings": { + "application_name": application_name, + "jit": "off", # 禁用 JIT 编译,提高简单查询性能 + "statement_timeout": "30000" # 30 秒查询超时 + } + }, + pool_size=pool_size, + max_overflow=max_overflow, + pool_timeout=pool_timeout, + pool_recycle=pool_recycle, + pool_pre_ping=pool_pre_ping, + pool_use_lifo=True, # 使用 LIFO 提高连接池效率 + # PostgreSQL 特定优化参数 + poolclass=None, # 使用默认 QueuePool + execution_options={ + "isolation_level": "REPEATABLE READ", # 推荐隔离级别 + "compiled_cache": None # 禁用缓存,避免内存泄漏 + } + ) + except Exception as e: + log.error(f'❌ PostgreSQL 数据库连接失败: {e}') + sys.exit(1) + else: + # 创建异步会话工厂 (针对 PostgreSQL 优化) + db_session = async_sessionmaker( + bind=engine, + autoflush=False, + expire_on_commit=False, + # PostgreSQL 特定优化 + class_=AsyncSession, + twophase=False, # 禁用两阶段提交 + enable_baked_queries=False, # 禁用 baked 查询避免内存问题 + info={"app_name": application_name} # 添加应用标识 + ) + + log.info(f'✅ PostgreSQL 异步引擎创建成功 | 连接池: [{pool_size}] - [{max_overflow}]') + return engine, db_session + + +async def get_db(): + """session 生成器""" + async with async_db_session() as session: + yield session + + +async def create_table() -> None: + """创建数据库表""" + async with async_engine.begin() as coon: + await coon.run_sync(MappedBase.metadata.create_all) + + +def uuid4_str() -> str: + """数据库引擎 UUID 类型兼容性解决方案""" + return str(uuid4()) + + +SQLALCHEMY_DATABASE_URL = get_db_uri(settings) + +async_engine, async_db_session = create_async_engine_and_session(SQLALCHEMY_DATABASE_URL) + +# Session Annotated +CurrentSession = Annotated[AsyncSession, Depends(get_db)] diff --git a/backend/database/redis.py b/backend/database/redis.py new file mode 100755 index 0000000..40e81a1 --- /dev/null +++ b/backend/database/redis.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import sys + +from redis.asyncio import Redis +from redis.exceptions import AuthenticationError, TimeoutError + +from backend.common.log import log +from backend.core.conf import settings + + +class RedisCli(Redis): + def __init__(self): + super(RedisCli, self).__init__( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + password=settings.REDIS_PASSWORD, + db=settings.REDIS_DATABASE, + socket_timeout=settings.REDIS_TIMEOUT, + decode_responses=True, # 转码 utf-8 + ) + + async def open(self): + """ + 触发初始化连接 + + :return: + """ + try: + await self.ping() + except TimeoutError: + log.error('❌ 数据库 redis 连接超时') + sys.exit() + except AuthenticationError: + log.error('❌ 数据库 redis 连接认证失败') + sys.exit() + except Exception as e: + log.error('❌ 数据库 redis 连接异常 {}', e) + sys.exit() + + async def delete_prefix(self, prefix: str, exclude: str | list = None): + """ + 删除指定前缀的所有key + + :param prefix: + :param exclude: + :return: + """ + keys = [] + async for key in self.scan_iter(match=f'{prefix}*'): + if isinstance(exclude, str): + if key != exclude: + keys.append(key) + elif isinstance(exclude, list): + if key not in exclude: + keys.append(key) + else: + keys.append(key) + if keys: + await self.delete(*keys) + + +# 创建 redis 客户端单例 +redis_client: RedisCli = RedisCli() diff --git a/backend/main.py b/backend/main.py new file mode 100755 index 0000000..dd1cf62 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from pathlib import Path + +import uvicorn + +from backend.core.registrar import register_app +from backend.core.conf import settings + +app = register_app() + +@app.get("/") +def read_root(): + return {"Hello": "World"} + + +if __name__ == '__main__': + try: + config = uvicorn.Config( + app=f'{Path(__file__).stem}:app', + host=settings.SERVER_HOST, port=settings.SERVER_PORT, + reload=True + ) + server = uvicorn.Server(config) + server.run() + except Exception as e: + raise e diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100755 index 0000000..6f7b18c --- /dev/null +++ b/backend/middleware/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/middleware/access_middle.py b/backend/middleware/access_middle.py new file mode 100755 index 0000000..c65f099 --- /dev/null +++ b/backend/middleware/access_middle.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + +from backend.common.log import log +from backend.utils.timezone import timezone + + +class AccessMiddleware(BaseHTTPMiddleware): + """请求日志中间件""" + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + start_time = timezone.now() + response = await call_next(request) + end_time = timezone.now() + log.info( + f'{request.client.host: <15} | {request.method: <8} | {response.status_code: <6} | ' + f'{request.url.path} | {round((end_time - start_time).total_seconds(), 3) * 1000.0}ms' + ) + return response diff --git a/backend/middleware/en-US_2.wav b/backend/middleware/en-US_2.wav new file mode 100644 index 0000000..17b6d1a Binary files /dev/null and b/backend/middleware/en-US_2.wav differ diff --git a/backend/middleware/id_conversion.py b/backend/middleware/id_conversion.py new file mode 100755 index 0000000..e6525d1 --- /dev/null +++ b/backend/middleware/id_conversion.py @@ -0,0 +1,51 @@ +from fastapi import FastAPI, Request +from starlette.middleware.base import BaseHTTPMiddleware + +app = FastAPI() + + +class IDConversionMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + # 1. 处理路径参数 + if request.path_params: + request.path_params = self.convert_ids_in_dict(request.path_params) + + # 2. 处理查询参数 + if request.query_params: + query_params = dict(request.query_params) + request.scope["query_string"] = self.convert_query_string(query_params) + + # 3. 处理请求体(需要谨慎,可能影响性能) + # 对于大型请求体,建议在路由级别处理 + + response = await call_next(request) + return response + + def convert_ids_in_dict(self, data: dict) -> dict: + """转换字典中所有ID字段""" + converted = {} + for key, value in data.items(): + if key.endswith("_id") or key == "id": + try: + converted[key] = int(value) + except (ValueError, TypeError): + converted[key] = value # 保留原始值 + else: + converted[key] = value + return converted + + def convert_query_string(self, query_params: dict) -> bytes: + """转换查询字符串中的ID字段""" + converted = [] + for key, value in query_params.items(): + if isinstance(value, list): + # 处理多值参数 + values = [str(int(v)) if (key.endswith("_id") or key == "id") and v.isdigit() else v for v in value] + converted.extend([f"{key}={v}" for v in values]) + else: + if (key.endswith("_id") or key == "id") and value.isdigit(): + converted.append(f"{key}={int(value)}") + else: + converted.append(f"{key}={value}") + return "&".join(converted).encode() + diff --git a/backend/middleware/jwt_auth_middleware.py b/backend/middleware/jwt_auth_middleware.py new file mode 100755 index 0000000..43b84ee --- /dev/null +++ b/backend/middleware/jwt_auth_middleware.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from typing import Any + +from fastapi import Request, Response +from fastapi.security.utils import get_authorization_scheme_param +from starlette.authentication import AuthCredentials, AuthenticationBackend, AuthenticationError +from starlette.requests import HTTPConnection + +from backend.app.admin.schema.wx import GetWxUserInfoWithRelationDetail +from backend.common.exception.errors import TokenError +from backend.common.log import log +from backend.common.security.jwt import jwt_authentication +from backend.core.conf import settings +from backend.utils.serializers import MsgSpecJSONResponse + + +class _AuthenticationError(AuthenticationError): + """重写内部认证错误类""" + + def __init__( + self, *, code: int | None = None, msg: str | None = None, headers: dict[str, Any] | None = None + ) -> None: + """ + 初始化认证错误 + + :param code: 错误码 + :param msg: 错误信息 + :param headers: 响应头 + :return: + """ + self.code = code + self.msg = msg + self.headers = headers + + +class JwtAuthMiddleware(AuthenticationBackend): + """JWT 认证中间件""" + + @staticmethod + def auth_exception_handler(conn: HTTPConnection, exc: _AuthenticationError) -> Response: + """ + 覆盖内部认证错误处理 + + :param conn: HTTP 连接对象 + :param exc: 认证错误对象 + :return: + """ + return MsgSpecJSONResponse(content={'code': exc.code, 'msg': exc.msg, 'data': None}, status_code=exc.code) + + async def authenticate(self, request: Request) -> tuple[AuthCredentials, GetWxUserInfoWithRelationDetail] | None: + """ + 认证请求 + + :param request: FastAPI 请求对象 + :return: + """ + token = request.headers.get('Authorization') + if not token: + return None + + if request.url.path in settings.TOKEN_REQUEST_PATH_EXCLUDE: + return None + + scheme, token = get_authorization_scheme_param(token) + if scheme.lower() != 'bearer': + return None + + try: + user = await jwt_authentication(token) + except TokenError as exc: + raise _AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers) + except Exception as e: + log.exception(f'JWT 授权异常:{e}') + raise _AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error')) + + # 请注意,此返回使用非标准模式,所以在认证通过时,将丢失某些标准特性 + # 标准返回模式请查看:https://www.starlette.io/authentication/ + return AuthCredentials(['authenticated']), user diff --git a/backend/middleware/qwen.py b/backend/middleware/qwen.py new file mode 100755 index 0000000..d6a517d --- /dev/null +++ b/backend/middleware/qwen.py @@ -0,0 +1,387 @@ +from datetime import datetime + +import requests +import time +import json +import dashscope +from dashscope.api_entities.dashscope_response import DashScopeAPIResponse +from starlette.background import BackgroundTasks + +from backend.app.admin.model.audit_log import AuditLog +from backend.app.admin.schema.audit_log import CreateAuditLogParam +from backend.app.admin.schema.qwen import QwenEmbedImageParams, QwenRecognizeImageParams +from backend.app.admin.service.audit_log_service import audit_log_service +from backend.common.exception import errors +from backend.core.conf import settings +from backend.common.log import log as logger +from typing import Dict, Any + +from backend.database.db import async_db_session + + +class Qwen: + # API端点配置 + RECOGNITION_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" + EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/multimodal-embedding" + + @staticmethod + def get_recognition_prompt(type: str, exclude_words: list[str] = None) -> str: + """获取图像识别提示词""" + # 根据dict_level确定词汇级别 + vocabulary_level = "elementary level" + specificity = "basic and common" + + # if dict_level: + # if dict_level == "LEVEL1": + # vocabulary_level = "elementary level" + # specificity = "basic and common" + # elif dict_level == "LEVEL2": + # vocabulary_level = "junior high school level" + # specificity = "more specific and detailed" + # elif dict_level == "LEVEL3": + # vocabulary_level = "college English test level" + # specificity = "precise and technical" + # elif dict_level == "LEVEL4": + # vocabulary_level = "TOEFL/IELTS level" + # specificity = "highly specialized and academic" + + if type == 'word': + prompt = ( + "Vision-to-English education module." + "Analyze image. Output JSON: " + "Output JSON: {LEVEL1: [{description: str, desc_ipa:str, ref_word: str, word_ipa: str}, ...], LEVEL2: {...}, LEVEL3: {...}}. " + # "Output JSON: {LEVEL1: [{description: str, desc_ipa:str, desc_zh: str, ref_word: str, word_ipa: str}, ...], LEVEL2: {...}, LEVEL3: {...}}. " + "Each level: 4 singular lowercase nouns(single-word only, no hyphens or compounds) with one 20-word description each." + "And each description must have a corresponding International Phonetic Alphabet (IPA) transcription in the 'desc_ipa' field." + # "And each description must have a corresponding Chinese translation in 'desc_zh' field." + "Vocabulary progression: basic and common → some details and specific → technical and academic. " + "Ensure all ref_words are unique across levels - no repetition." + # "Focus: primary/central/artificial objects." + ) + if exclude_words: + exclude_str = ", ".join(exclude_words) + prompt += f"Avoid using these words: {exclude_str}." + + return prompt + elif type == 'food': + return ( + "你是一个专业美食识别AI,请严格按以下步骤分析图片:\n" + "1. 识别最显著菜品名称(需具体到品种/烹饪方式):\n" + "- 示例:清蒸鲈鱼(非清蒸鱼)、罗宋汤(非蔬菜汤)\n" + "- 无法确定具体菜品时返回“无法识别出菜品”\n" + "2. 提取核心食材(3-5种主料):\n" + "- 排除调味料(油/盐/酱油等)\n" + "- 混合菜(如沙拉/炒饭)列出可见食材\n" + "- 无法识别时写“未知”\n" + "3. 输出格式(严格JSON), 如果有多个占据显著位置的菜品,可以将多个菜品罗列出来放到 json 数组中:\n" + "[{ dish_name: 具体菜品名1 | 无法识别出菜品, method: 烹饪方式, main_ingredients: [食材1, 食材2] },\n" + "{ dish_name: 具体菜品名2 | 无法识别出菜品, method: 烹饪方式, main_ingredients: [食材1, 食材2] }]" + ) + else: + return "" + + @staticmethod + def recognize_image(params: QwenRecognizeImageParams, background_tasks: BackgroundTasks) -> Dict[str, Any]: + """调用通义千问API识别图像内容,支持词典等级参数""" + image_data = f"data:image/{params.format};base64,{params.data}" + return Qwen._call_api( + api_type="recognition", + dict_level=params.dict_level, + image_id=params.image_id, + user_id=params.user_id, + background_tasks=background_tasks, + input=[ + { + "role": "user", + "content": [ + {"image": image_data}, + {"text": Qwen.get_recognition_prompt(params.type, params.exclude_words)} + ] + } + ] + ) + + @staticmethod + def embed_image(params: QwenEmbedImageParams, background_tasks: BackgroundTasks) -> Dict[str, Any]: + """调用通义千问API获取图像嵌入向量""" + image_data = f"data:image/{params.format};base64,{params.data}" + return Qwen._call_api( + api_type="embedding", + dict_level=params.dict_level, + image_id=params.image_id, + user_id=params.user_id, + background_tasks=background_tasks, + input=[{'image': image_data}] + ) + + @staticmethod + def _call_api(api_type: str, image_id: int, user_id: int, background_tasks: BackgroundTasks, + input: dict | list, dict_level: str=None) -> \ + Dict[str, Any]: + """通用API调用方法""" + + model_name = "" + api_key = settings.QWEN_API_KEY + response = {} + start_time = time.time() + start_at = datetime.now() + response_data = {} + status_code = 500 + error_message = "" + + try: + if api_type == "recognition": + model_name = settings.QWEN_VISION_MODEL + response = dashscope.MultiModalConversation.call( + api_key=api_key, + model=model_name, + messages=input, + ) + elif api_type == "embedding": + model_name = settings.QWEN_VISION_EMBEDDING_MODEL + response = dashscope.MultiModalEmbedding.call( + api_key=api_key, + model=model_name, + input=input, + ) + else: + raise errors.ForbiddenError(msg=f'Qwen 不支持类型[{api_type}]') + + status_code = response.status_code + duration = time.time() - start_time + + if status_code == 200: + response_data = response + + # 处理异步响应 + if response_data.get("output", {}).get("task_status") == "PENDING": + return Qwen._handle_async_response(api_type, model_name, input, response_data, duration, dict_level) + + audit_log = CreateAuditLogParam( + api_type=api_type, + model_name=model_name, + response_data=response_data, + token_usage=response_data.get("usage", 0), + duration=duration, + status_code=status_code, + error_message=error_message, + called_at=start_at, + image_id=image_id, + user_id=user_id, + api_version=settings.FASTAPI_API_V1_PATH, + dict_level=dict_level, + ) + + # 记录审计日志 + Qwen._audit_log(api_type, audit_log, background_tasks) + + return Qwen._parse_response(api_type, response_data) + else: + error_message = f"API error: {response.text}" + logger.error(f"{api_type} API error: {status_code} - {error_message}") + return { + "success": False, + "error": error_message, + "status_code": status_code + } + except Exception as e: + error_message = str(e) + logger.exception(f"{api_type} API exception: {error_message}") + return { + "success": False, + "error": error_message + } + finally: + # 确保所有调用都记录审计日志 + if error_message: + Qwen._log_audit( + api_type=api_type, + dict_level=dict_level, + model_name=model_name, + request_data=input, + response_data={"error": error_message}, + duration=time.time() - start_time, + status_code=status_code, + error_message=error_message + ) + + @staticmethod + def _handle_async_response(api_type: str, model_name: str, request_data: dict, + initial_response: dict, initial_duration: float, dict_level: str) -> dict: + """处理异步API响应""" + task_id = initial_response.get("output", {}).get("task_id") + if not task_id: + return { + "success": False, + "error": "Async task ID missing" + } + + # 记录初始调用审计日志 + Qwen._log_audit( + api_type=api_type, + dict_level=dict_level, + model_name=model_name, + request_data=request_data, + response_data=initial_response, + duration=initial_duration, + status_code=202 + ) + + # 轮询任务状态 + start_time = time.time() + headers = { + "Authorization": f"Bearer {settings.QWEN_API_KEY}", + "Content-Type": "application/json" + } + + while True: + time.sleep(settings.ASYNC_POLL_INTERVAL) + try: + response = requests.get( + f"https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}", + headers=headers + ) + + if response.status_code == 200: + task_data = response.json() + task_status = task_data.get("output", {}).get("task_status") + total_duration = time.time() - start_time + initial_duration + + if task_status == "SUCCEEDED": + + # 记录最终审计日志 + Qwen._log_audit( + api_type=api_type, + dict_level=dict_level, + model_name=request_data.get("model", ""), + request_data=request_data, + response_data=task_data, + duration=total_duration, + status_code=200 + ) + + return Qwen._parse_response(api_type, task_data) + + elif task_status in ["FAILED", "CANCELED"]: + error_message = task_data.get("message", "Async task failed") + logger.error(f"Async task failed: {task_id} - {error_message}") + + Qwen._log_audit( + api_type=api_type, + dict_level=dict_level, + model_name=request_data.get("model", ""), + request_data=request_data, + response_data=task_data, + duration=total_duration, + status_code=500, + error_message=error_message, + ) + + return { + "success": False, + "error": error_message + } + else: + error_message = f"Task status check failed: {response.text}" + logger.error(error_message) + return { + "success": False, + "error": error_message + } + except Exception as e: + error_message = str(e) + logger.exception(f"Async task polling error: {error_message}") + return { + "success": False, + "error": error_message + } + + @staticmethod + def _parse_response(api_type: str, response_data: DashScopeAPIResponse) -> dict: + """解析API响应""" + if api_type == "recognition": + # 解析识别结果 + result = response_data.output.get("choices", [{}])[0].get("message", {}).get("content", "") + if isinstance(result, list): + result = " ".join([item.get("text", "") for item in result if item.get("text")]) + + return { + "success": True, + "result": result.strip().lower(), + "token_usage": response_data.get("usage", {}) + } + elif api_type == "embedding": + # 解析嵌入向量 + embeddings = response_data.output.get("embeddings", []) + if embeddings and embeddings[0].get("embedding"): + embedding = embeddings[0]["embedding"] + return { + "success": True, + "embedding": embedding, + "token_usage": response_data.get("usage", {}) + } + return { + "success": False, + "error": "No embedding found in response" + } + else: + return {} + + @staticmethod + def _calculate_cost(api_type: str, token_usage: dict) -> float: + """Calculate API call cost based on API type and token usage""" + cost = 0 + if api_type == "recognition": + # Cost calculation: input_tokens * 0.0016 + output_tokens * 0.004 + input_tokens = token_usage.get("input_tokens", 0) + output_tokens = token_usage.get("output_tokens", 0) + # cost = input_tokens * 0.0016 + output_tokens * 0.004 + cost = input_tokens * 0.001 + output_tokens * 0.01 + if cost > 0: + cost = cost / 1000 + elif api_type == "embedding": + # 假设每张图片 $0.001 + cost = 0.01 + return cost + + @staticmethod + def _audit_log(api_type: str, params: CreateAuditLogParam, background_tasks: BackgroundTasks): + # Set cost using the shared calculation method + params.cost = Qwen._calculate_cost(api_type, params.token_usage) + + background_tasks.add_task( + audit_log_service.create, + obj=params + ) + + @staticmethod + def _log_audit(api_type: str, model_name: str, request_data: dict, + response_data: dict, duration: float, status_code: int, + error_message: str = None, dict_level: str = None): + """记录API调用审计日志""" + token_usage = response_data.get("usage", 0) + + # Calculate cost using the shared calculation method + cost = Qwen._calculate_cost(api_type, token_usage) + + audit_log = CreateAuditLogParam( + api_type=api_type, + model_name=model_name, + request_data=request_data, + response_data=response_data, + token_usage=token_usage, + cost=cost, + duration=duration, + status_code=status_code, + error_message=error_message, + user_id=0, + api_version=settings.FASTAPI_API_V1_PATH, + dict_level=dict_level, + ) + + try: + audit_log_service.create(audit_log) + with async_db_session.begin() as db: + db.add(audit_log) + except Exception as e: + logger.error(f"Failed to save audit log: {str(e)}") \ No newline at end of file diff --git a/backend/middleware/speaking_assessment.py b/backend/middleware/speaking_assessment.py new file mode 100644 index 0000000..8c9fdaf --- /dev/null +++ b/backend/middleware/speaking_assessment.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +import sys +import hmac +import hashlib +import base64 +import time +import json +import threading +import urllib + +import websocket +import uuid +from urllib.parse import quote + +class Credential: + def __init__(self, secret_id, secret_key, token=""): + self.secret_id = secret_id + self.secret_key = secret_key + self.token = token + + +class SpeakingAssessmentListener(): + ''' + reponse: + on_recognition_start的返回只有voice_id字段。 + on_fail 只有voice_id、code、message字段。 + on_recognition_complete没有result字段。 + 其余消息包含所有字段。 + 字段名 类型 + code Integer + message String + voice_id String + message_id String + result + final Integer + + # Result的结构体格式为: + # slice_type Integer + # index Integer + # start_time Integer + # end_time Integer + # voice_text_str String + # word_size Integer + # word_list Word Array + # + # Word的类型为: + # word String + # start_time Integer + # end_time Integer + # stable_flag:Integer + ''' + + def on_recognition_start(self, response): + pass + + def on_intermediate_result(self, response): + pass + + def on_recognition_complete(self, response): + pass + + def on_fail(self, response): + pass + + +NOTOPEN = 0 +STARTED = 1 +OPENED = 2 +FINAL = 3 +ERROR = 4 +CLOSED = 5 + + +def quote_autho(autho): + if sys.version_info >= (3, 0): + import urllib.parse as urlparse + return urlparse.quote(autho) + else: + return urllib.quote(autho) + + +# 实时识别使用 +class SpeakingAssessment: + + def __init__(self, appid, credential, engine_model_type, listener): + self.result = "" + self.credential = credential + self.appid = appid + self.server_engine_type = engine_model_type + self.status = NOTOPEN + self.ws = None + self.wst = None + self.voice_id = "" + self.new_start = 0 + self.listener = listener + self.text_mode = 0 + self.ref_text = "" + self.keyword = "" + self.eval_mode = 0 + self.score_coeff = 1.0 + self.sentence_info_enabled = 0 + self.voice_format = 0 + self.nonce = "" + self.rec_mode = 0 + + def set_voice_id(self, voice_id): + self.voice_id = voice_id + + def set_text_mode(self, text_mode): + self.text_mode = text_mode + + def set_rec_mode(self, rec_mode): + self.rec_mode = rec_mode + + def set_ref_text(self, ref_text): + self.ref_text = ref_text + + def set_keyword(self, keyword): + self.keyword = keyword + + def set_eval_mode(self, eval_mode): + self.eval_mode = eval_mode + + def set_sentence_info_enabled(self, sentence_info_enabled): + self.sentence_info_enabled = sentence_info_enabled + + def set_voice_format(self, voice_format): + self.voice_format = voice_format + + def set_nonce(self, nonce): + self.nonce = nonce + + def format_sign_string(self, param): + signstr = "soe.cloud.tencent.com/soe/api/" + for t in param: + if 'appid' in t: + signstr += str(t[1]) + break + signstr += "?" + for x in param: + tmp = x + if 'appid' in x: + continue + for t in tmp: + signstr += str(t) + signstr += "=" + signstr = signstr[:-1] + signstr += "&" + signstr = signstr[:-1] + return signstr + + def create_query_string(self, param): + signstr = "" + for key, value in param.items(): + if key == 'appid': + signstr += str(value) + break + signstr += "?" + for key, value in param.items(): + if key == 'appid': + continue + value = quote_autho(str(value)) + signstr += str(key) + "=" + str(value) + "&" + signstr = signstr[:-1] + return "wss://soe.cloud.tencent.com/soe/api/" + signstr + + def sign(self, signstr, secret_key): + hmacstr = hmac.new(secret_key.encode('utf-8'), + signstr.encode('utf-8'), hashlib.sha1).digest() + s = base64.b64encode(hmacstr) + s = s.decode('utf-8') + return s + + def create_query_arr(self): + query_arr = dict() + + query_arr['appid'] = self.appid + query_arr['server_engine_type'] = self.server_engine_type + query_arr['text_mode'] = self.text_mode + query_arr['rec_mode'] = self.rec_mode + query_arr['ref_text'] = self.ref_text + query_arr['keyword'] = self.keyword + query_arr['eval_mode'] = self.eval_mode + query_arr['score_coeff'] = self.score_coeff + query_arr['sentence_info_enabled'] = self.sentence_info_enabled + query_arr['secretid'] = self.credential.secret_id + if self.credential.token != "": + query_arr['token'] = self.credential.token + query_arr['voice_format'] = self.voice_format + query_arr['voice_id'] = self.voice_id + query_arr['timestamp'] = str(int(time.time())) + if self.nonce != "": + query_arr['nonce'] = self.nonce + else: + query_arr['nonce'] = query_arr['timestamp'] + query_arr['expired'] = int(time.time()) + 24 * 60 * 60 + return query_arr + + def stop(self): + if self.status == OPENED: + msg = {'type': "end"} + text_str = json.dumps(msg) + self.ws.sock.send(text_str) + if self.ws: + if self.wst and self.wst.is_alive(): + self.wst.join() + self.ws.close() + + def write(self, data): + while self.status == STARTED: + time.sleep(0.1) + if self.status == OPENED: + self.ws.sock.send_binary(data) + + def start(self): + def on_message(ws, message): + # print(message) + response = json.loads(message) + response['voice_id'] = self.voice_id + if response['code'] != 0: + # logger.error("%s server recognition fail %s" % (response['voice_id'], response['message'])) + self.listener.on_fail(response) + return + if "final" in response and response["final"] == 1: + self.status = FINAL + self.result = message + self.listener.on_recognition_complete(response) + # logger.info("%s recognition complete" % response['voice_id']) + self.ws.close() + return + else: + if response["result"] is not None: + self.listener.on_intermediate_result(response) + # logger.info("%s recognition doing" % response['voice_id']) + return + + def on_error(ws, error): + if self.status == FINAL: + return + # logger.error("websocket error %s voice id %s" % (format(error), self.voice_id)) + self.status = ERROR + + def on_close(ws, error, msg): + self.status = CLOSED + # logger.info("websocket closed voice id %s" % self.voice_id) + + def on_open(ws): + self.status = OPENED + + query_arr = self.create_query_arr() + if self.voice_id == "": + query_arr['voice_id'] = str(uuid.uuid1()) + self.voice_id = query_arr['voice_id'] + query = sorted(query_arr.items(), key=lambda d: d[0]) + signstr = self.format_sign_string(query) + autho = self.sign(signstr, self.credential.secret_key) + requrl = self.create_query_string(query_arr) + # print(requrl) + autho = urllib.parse.quote(autho) + requrl += "&signature=%s" % autho + # print(requrl) + self.ws = websocket.WebSocketApp(requrl, None, + on_error=on_error, on_close=on_close, on_message=on_message) + self.ws.on_open = on_open + self.wst = threading.Thread(target=self.ws.run_forever) + self.wst.daemon = True + self.wst.start() + self.status = STARTED + response = {'voice_id': self.voice_id} + self.listener.on_recognition_start(response) \ No newline at end of file diff --git a/backend/middleware/tencent_cloud.py b/backend/middleware/tencent_cloud.py new file mode 100644 index 0000000..579b88f --- /dev/null +++ b/backend/middleware/tencent_cloud.py @@ -0,0 +1,517 @@ +import asyncio +import base64 +import json +import time +import uuid +import hmac +import hashlib +import os +import io +from argparse import Action +from typing import Optional, Dict, Any, Tuple +from collections import OrderedDict +import logging +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor +from http.client import HTTPSConnection + +from fastapi import UploadFile + +from backend.core.conf import settings +from backend.app.admin.service.file_service import file_service +from backend.app.admin.service.audit_log_service import audit_log_service +from backend.app.admin.schema.audit_log import CreateAuditLogParam +from backend.middleware.speaking_assessment import Credential, SpeakingAssessment + +# 设置日志 +logger = logging.getLogger(__name__) + +# 用于管理相同 voice_id 的并发请求 +voice_id_locks = {} +voice_id_lock = asyncio.Lock() + +# 线程池用于处理阻塞的语音识别操作 +thread_pool = ThreadPoolExecutor(max_workers=10) + + +class ConnectionPool: + """WebSocket连接池管理""" + + def __init__(self, max_connections: int = 10): + self.max_connections = max_connections + self.connections = OrderedDict() # 使用有序字典维护LRU + self.lock = asyncio.Lock() + + async def get_connection(self, key: str) -> Optional[Tuple]: + """获取连接""" + async with self.lock: + if key in self.connections: + # 移动到末尾(最近使用) + conn = self.connections.pop(key) + self.connections[key] = conn + return conn + return None + + async def put_connection(self, key: str, connection: Tuple) -> None: + """放入连接""" + async with self.lock: + if key in self.connections: + # 如果已存在,先关闭旧连接 + old_conn = self.connections[key] + try: + await old_conn[0].close() + except: + pass + + # 如果连接池已满,移除最久未使用的连接 + if len(self.connections) >= self.max_connections: + oldest_key, oldest_conn = self.connections.popitem(last=False) + try: + await oldest_conn[0].close() + except: + pass + + # 添加新连接 + self.connections[key] = connection + + async def remove_connection(self, key: str) -> None: + """移除连接""" + async with self.lock: + if key in self.connections: + conn = self.connections.pop(key) + try: + await conn[0].close() + except: + pass + + async def close_all(self) -> None: + """关闭所有连接""" + async with self.lock: + for key, (websocket, voice_id, created_at) in self.connections.items(): + try: + await websocket.close() + except: + pass + self.connections.clear() + + +class TencentCloud: + """腾讯云SOE(语音评测)服务""" + + def __init__(self): + self.APPID = settings.TENCENT_CLOUD_APP_ID + self.SECRET_ID = settings.TENCENT_CLOUD_SECRET_ID + self.SECRET_KEY = settings.TENCENT_CLOUD_SECRET_KEY + self.ENGINE_MODEL_TYPE = settings.TENCENT_CLOUD_ENGINE_MODEL_TYPE + self.SLICE_SIZE = 32000 # 对应wav格式200ms音频 + self.connection_pool = ConnectionPool() + + async def assessment_speech(self, file_id: int, ref_text: str, voice_id: str = None, image_id: int = None, user_id: int = None) -> Dict[str, Any]: + """ + 识别语音文件 + + :param file_id: 录音文件ID + :param ref_text: 参考文本 + :param voice_id: 语音ID,如果不提供则自动生成 + :return: 识别结果 + """ + start_time = time.time() + result = {} + error_message = "" + status_code = 200 + + # 获取文件数据 + try: + file_content, file_name, content_type = await file_service.download_file(file_id) + except Exception as e: + logger.error(f"Failed to download file {file_id}: {str(e)}") + error_message = f"Failed to download file: {str(e)}" + status_code = 500 + return {"error": error_message, "code": status_code} + + # 如果没有提供 voice_id,则生成一个 + if not voice_id: + voice_id = str(uuid.uuid4()) + + # 处理相同 voice_id 的并发请求 + async with voice_id_lock: + if voice_id not in voice_id_locks: + voice_id_locks[voice_id] = asyncio.Lock() + + async with voice_id_locks[voice_id]: + try: + # 在线程池中执行阻塞的语音识别操作 + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + thread_pool, + self._perform_speech_recognition, + file_content, ref_text, voice_id + ) + + # 检查结果是否包含错误 + if "error" in result: + error_message = result.get("error", "Recognition failed") + status_code = result.get("code", 500) + + except Exception as e: + logger.error(f"Speech recognition failed for file {file_id}: {str(e)}") + error_message = str(e) + status_code = 500 + result = {"error": str(e)} + finally: + # 清理资源 + if voice_id in voice_id_locks: + async with voice_id_lock: + if voice_id in voice_id_locks and not voice_id_locks[voice_id]._waiters: + del voice_id_locks[voice_id] + + # 记录审计日志 + await self._log_assessment_audit( + file_id=file_id, + ref_text=ref_text, + voice_id=voice_id, + image_id=image_id, + user_id=user_id, + start_time=start_time, + result=result, + error_message=error_message, + status_code=status_code + ) + + return result + + def _perform_speech_recognition(self, file_content: bytes, ref_text: str, voice_id: str) -> Dict[str, Any]: + """ + 在线程池中执行实际的语音识别操作 + + :param file_content: 音频文件内容 + :param ref_text: 参考文本 + :param voice_id: 语音ID + :return: 识别结果 + """ + try: + # 创建识别监听器 + listener = SpeechRecognitionListener(voice_id) + + # 创建凭证 + credential_var = Credential(self.SECRET_ID, self.SECRET_KEY) + + # 创建识别器 + recognizer = SpeakingAssessment( + self.APPID, credential_var, self.ENGINE_MODEL_TYPE, listener) + + # 设置识别参数 + recognizer.set_text_mode(0) + recognizer.set_ref_text(ref_text) + recognizer.set_eval_mode(1) # 句子模式 + recognizer.set_keyword("") + recognizer.set_sentence_info_enabled(0) + recognizer.set_voice_format(2) # 1: WAV格式 2: mp3 + recognizer.set_rec_mode(1) # 录音识别模式,可发送单个大长度分片 + recognizer.set_voice_id(voice_id) + + # 启动识别 + try: + recognizer.start() + recognizer.write(file_content) + except Exception as e: + print(e) + finally: + recognizer.stop() + + return listener.result + + except Exception as e: + logger.error(f"Speech recognition failed in thread: {str(e)}") + return {"error": str(e), "code": 500} + + async def _log_assessment_audit(self, file_id: int, ref_text: str, voice_id: str, image_id: int, user_id: int, start_time: float, + result: Dict, error_message: str, status_code: int) -> None: + """记录审计日志""" + try: + duration = time.time() - start_time + audit_log = CreateAuditLogParam( + api_type="assessment", + model_name="tencent_soe", + request_data={ + "file_id": file_id, + "ref_text": ref_text, + "voice_id": voice_id + }, + response_data=result, + token_usage={}, + cost=0.0, + duration=duration, + status_code=status_code, + error_message=error_message, + called_at=datetime.now(), + user_id=user_id, + image_id=image_id, + api_version="v1", + dict_level=None + ) + await audit_log_service.create(obj=audit_log) + except Exception as e: + logger.error(f"Failed to create audit log: {str(e)}") + + def _sign(self, key: bytes, msg: str) -> bytes: + """签名方法""" + return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() + + async def text_to_speak(self, content: str, image_text_id: int = None, image_id: int = None, user_id: int = None) -> Dict[str, Any]: + """ + 将文本转换为语音,并创建标准发音录音记录 + + :param content: 要转换为语音的文本内容 + :param image_text_id: 关联的图片文本ID + :param image_id: 关联的图片ID + :param user_id: 用户ID + :return: 包含标准发音音频信息的结果 + """ + start_time = time.time() + result = {} + error_message = "" + status_code = 200 + + # 如果没有提供image_id但提供了image_text_id,则从image_text获取image_id + if image_id is None and image_text_id is not None: + try: + from backend.app.ai.service.image_text_service import image_text_service + image_text = await image_text_service.get_text_by_id(image_text_id) + if image_text: + image_id = image_text.image_id + except Exception as e: + logger.warning(f"Failed to get image_id from image_text_id {image_text_id}: {str(e)}") + + try: + # 准备TTS API调用参数 + service = "tts" + host = "tts.tencentcloudapi.com" + version = "2019-08-23" + action = "TextToVoice" + + # 构造请求参数 + params = { + "Text": content, + "SessionId": str(uuid.uuid4()), + "ModelType": 1, # 默认模型 + "Volume": 0, # 音量 + "Speed": 0, # 语速 + "ProjectId": 0, # 项目ID + "VoiceType": 101016, # 语音类型(英文) + "PrimaryLanguage": 1, # 主语言(英文) + "SampleRate": 16000, # 采样率 + "Codec": "mp3" # 音频格式 + } + + payload = json.dumps(params) + + # 构造签名 + algorithm = "TC3-HMAC-SHA256" + timestamp = int(time.time()) + date = datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d") + + # ************* 步骤 1:拼接规范请求串 ************* + http_request_method = "POST" + canonical_uri = "/" + canonical_querystring = "" + ct = "application/json; charset=utf-8" + canonical_headers = "content-type:%s\nhost:%s\nx-tc-action:%s\n" % (ct, host, action.lower()) + signed_headers = "content-type;host;x-tc-action" + hashed_request_payload = hashlib.sha256(payload.encode("utf-8")).hexdigest() + canonical_request = (http_request_method + "\n" + + canonical_uri + "\n" + + canonical_querystring + "\n" + + canonical_headers + "\n" + + signed_headers + "\n" + + hashed_request_payload) + + # ************* 步骤 2:拼接待签名字符串 ************* + credential_scope = date + "/" + service + "/" + "tc3_request" + hashed_canonical_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest() + string_to_sign = (algorithm + "\n" + + str(timestamp) + "\n" + + credential_scope + "\n" + + hashed_canonical_request) + + # ************* 步骤 3:计算签名 ************* + secret_date = self._sign(("TC3" + self.SECRET_KEY).encode("utf-8"), date) + secret_service = self._sign(secret_date, service) + secret_signing = self._sign(secret_service, "tc3_request") + signature = hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() + + # ************* 步骤 4:拼接 Authorization ************* + authorization = (algorithm + " " + + "Credential=" + self.SECRET_ID + "/" + credential_scope + ", " + + "SignedHeaders=" + signed_headers + ", " + + "Signature=" + signature) + + # ************* 步骤 5:构造并发起请求 ************* + headers = { + "Authorization": authorization, + "Content-Type": "application/json; charset=utf-8", + "Host": host, + "X-TC-Action": action, + "X-TC-Timestamp": str(timestamp), + "X-TC-Version": version + } + + # 发起请求 + req = HTTPSConnection(host) + req.request("POST", "/", headers=headers, body=payload.encode("utf-8")) + resp = req.getresponse() + response_data = resp.read() + + # 解析响应 + response_json = json.loads(response_data) + + if "Response" in response_json and "Audio" in response_json["Response"]: + # 成功获取音频数据 + audio_base64 = response_json["Response"]["Audio"] + audio_data = base64.b64decode(audio_base64) + + # 保存音频文件并创建标准发音录音记录 + clean_json = response_json.copy() + del clean_json["Response"]['Audio'] + recording_id = await self._create_standard_recording(audio_data, content, image_text_id, image_id, user_id, clean_json) + + result = { + "success": True, + "audio_data": audio_data, + "session_id": response_json["Response"].get("SessionId", ""), + "audio_duration": response_json["Response"].get("AudioDuration", 0), + "recording_id": recording_id + } + else: + # 处理错误 + error_message = response_json.get("Response", {}).get("Error", {}).get("Message", "TTS API call failed") + status_code = 500 + result = {"error": error_message} + + except Exception as e: + logger.error(f"Text to speech failed: {str(e)}") + error_message = str(e) + status_code = 500 + result = {"error": str(e)} + + # 创建一个干净的result对象,排除二进制数据 + clean_result = result.copy() if result else {} + if "audio_data" in clean_result: + del clean_result["audio_data"] # 移除二进制音频数据 + # 记录审计日志 + await self._log_tts_audit( + content=content, + image_text_id=image_text_id, + image_id=image_id or 0, # 确保image_id不为None + user_id=user_id or 0, # 确保user_id不为None + start_time=start_time, + result=clean_result, + error_message=error_message, + status_code=status_code + ) + + return result + + async def _log_tts_audit(self, content: str, image_text_id: int, image_id: int, user_id: int, start_time: float, + result: Dict, error_message: str, status_code: int) -> None: + """记录TTS API调用审计日志""" + try: + duration = time.time() - start_time + audit_log = CreateAuditLogParam( + api_type="tts", + model_name="tencent_tts", + request_data={ + "content": content, + "image_text_id": image_text_id + }, + response_data=result, + token_usage={}, + cost=(len(content) / 10000) * 2.4, # 2.4 全 / 1 万字 + duration=duration, + status_code=status_code, + error_message=error_message, + called_at=datetime.now(), + user_id=user_id, + image_id=image_id, + api_version="v1", + dict_level=None + ) + await audit_log_service.create(obj=audit_log) + except Exception as e: + logger.error(f"Failed to create TTS audit log: {str(e)}") + + async def _create_standard_recording(self, audio_data: bytes, content: str, image_text_id: int = None, image_id: int = None, user_id: int = None, details: dict = None) -> int: + """创建标准发音录音记录""" + try: + # 创建文件记录 + from backend.app.admin.service.file_service import file_service + from fastapi import UploadFile + import io + + # 创建一个模拟的UploadFile对象 + file_name = f"standard_audio_{uuid.uuid4().hex}.wav" + file_obj = io.BytesIO(audio_data) + + # 创建UploadFile对象 + upload_file = UploadFile( + filename=file_name, + file=file_obj, + headers={}, + size=len(audio_data) + ) + + # 上传文件 + file_response = await file_service.upload_file_with_content_type( + file=upload_file, + content_type="audio/mp3", + metadata={"is_standard_audio": True} + ) + + # 创建录音记录(包含详细信息) + from backend.app.ai.service.recording_service import recording_service + from backend.app.ai.model.recording import Recording + from backend.database.db import async_db_session + + # 使用新方法一次性创建录音记录并设置详细信息和标准音频标记 + recording_id = await recording_service.create_recording_record_with_details( + file_id=int(file_response.id), + ref_text=content, + image_id=image_id, + image_text_id=image_text_id, + eval_mode=1, # 句子模式 + user_id=user_id, + details=details, + is_standard=True # 设置为标准音频 + ) + + return recording_id + + except Exception as e: + logger.error(f"Failed to create standard recording: {str(e)}") + raise RuntimeError(f"Failed to create standard recording: {str(e)}") + + +class SpeechRecognitionListener: + """语音识别监听器""" + + def __init__(self, voice_id: str): + self.voice_id = voice_id + self.result = None + self.error = None + + def on_recognition_start(self, response): + logger.info(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}|{self.voice_id}|OnRecognitionStart") + + def on_intermediate_result(self, response): + rsp_str = json.dumps(response, ensure_ascii=False) + logger.info(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}|{self.voice_id}|OnIntermediateResults|{rsp_str}") + + def on_recognition_complete(self, response): + rsp_str = json.dumps(response, ensure_ascii=False) + # logger.info(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}|{self.voice_id}|OnRecognitionComplete|{rsp_str}") + self.result = response + + def on_fail(self, response): + rsp_str = json.dumps(response, ensure_ascii=False) + logger.error(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}|{self.voice_id}|OnFail|{rsp_str}") + self.error = response \ No newline at end of file diff --git a/backend/middleware/test.py b/backend/middleware/test.py new file mode 100644 index 0000000..320dd16 --- /dev/null +++ b/backend/middleware/test.py @@ -0,0 +1,123 @@ +# import time +# import sys +# import threading +# from datetime import datetime +# import json +# +# from backend.middleware import speaking_assessment +# from backend.middleware.speaking_assessment import Credential +# +# sys.path.append("../..") +# +# #TODO 补充账号信息 +# APPID = "1251798270" +# SECRET_ID = "AKIDZ3CIFMVcadaRCrQr9HfLxRdlhYG0b2MX" +# SECRET_KEY = "51oZRMWirvsgLqCORK6kFdOPMAylakqw" +# # 只有临时秘钥鉴权需要 +# TOKEN = "" +# ENGINE_MODEL_TYPE = "16k_en" +# # 对应wav格式200ms音频 +# SLICE_SIZE = 32000 +# +# +# +# class MySpeechRecognitionListener(speaking_assessment.SpeakingAssessmentListener): +# def __init__(self, id): +# self.id = id +# +# def on_recognition_start(self, response): +# print("%s|%s|OnRecognitionStart\n" % ( +# datetime.now().strftime("%Y-%m-%d %H:%M:%S"), response['voice_id'])) +# +# def on_intermediate_result(self, response): +# rsp_str = json.dumps(response, ensure_ascii=False) +# print("%s|%s|OnIntermediateResults|%s\n" % ( +# datetime.now().strftime("%Y-%m-%d %H:%M:%S"), response['voice_id'], rsp_str)) +# +# def on_recognition_complete(self, response): +# rsp_str = json.dumps(response, ensure_ascii=False) +# print("%s|%s|OnRecognitionComplete| %s\n" % ( +# datetime.now().strftime("%Y-%m-%d %H:%M:%S"), response['voice_id'], rsp_str)) +# +# def on_fail(self, response): +# rsp_str = json.dumps(response, ensure_ascii=False) +# print("%s|%s|OnFail,message %s\n" % (datetime.now().strftime( +# "%Y-%m-%d %H:%M:%S"), response['voice_id'], rsp_str)) +# +# # 流式识别模式 +# def process(id): +# audio = "en-US_0.wav" +# listener = MySpeechRecognitionListener(id) +# # 临时秘钥鉴权使用带token的方式 credential_var = credential.Credential(SECRET_ID, SECRET_KEY, TOKEN) +# credential_var = Credential(SECRET_ID, SECRET_KEY) +# recognizer = speaking_assessment.SpeakingAssessment( +# APPID, credential_var, ENGINE_MODEL_TYPE, listener) +# recognizer.set_text_mode(0) +# recognizer.set_ref_text("beautiful") +# recognizer.set_eval_mode(0) +# recognizer.set_keyword("") +# recognizer.set_sentence_info_enabled(0) +# recognizer.set_voice_format(1) +# try: +# recognizer.start() +# with open(audio, 'rb') as f: +# content = f.read(SLICE_SIZE) +# while content: +# recognizer.write(content) +# content = f.read(SLICE_SIZE) +# #sleep模拟实际实时语音发送间隔 +# # 注意:该行sleep代码用于模拟实时音频流1:1产生音频数据(每200ms产生200ms音频) +# # 实际音频流场景建议删除该行代码,或业务根据自己的需求情况自行调整 +# time.sleep(0.2) +# except Exception as e: +# print(e) +# finally: +# recognizer.stop() +# +# # 录音识别模式 +# def process_rec(id): +# # audio = "en-US_2.wav" +# audio = "tmp_6ca5f922df2512e5252e6f02522622a9.mp3" +# listener = MySpeechRecognitionListener(id) +# # 临时秘钥鉴权使用带token的方式 credential_var = credential.Credential(SECRET_ID, SECRET_KEY, TOKEN) +# credential_var = Credential(SECRET_ID, SECRET_KEY) +# recognizer = speaking_assessment.SpeakingAssessment( +# APPID, credential_var, ENGINE_MODEL_TYPE, listener) +# recognizer.set_text_mode(0) +# # recognizer.set_ref_text("Question the plausibility of one explanation sometimes offered for the dwarfing of certain species living on islands.") +# recognizer.set_ref_text("a large blue advertisement promoting real estate with chinese text and a building image.") +# recognizer.set_eval_mode(1) +# recognizer.set_keyword("") +# recognizer.set_sentence_info_enabled(0) +# recognizer.set_voice_format(2) # 1 wav 2 mp3 +# # 录音识别下可发送单个大长度分片(上限300s), 实时评测建议不要使用此模式 +# # 单次连接只能发一个分片传送完全部音频数据 +# recognizer.set_rec_mode(1) +# try: +# recognizer.start() +# with open(audio, 'rb') as f: +# content = f.read() +# recognizer.write(content) +# except Exception as e: +# print(e) +# finally: +# recognizer.stop() +# +# +# def process_multithread(number): +# thread_list = [] +# for i in range(0, number): +# thread = threading.Thread(target=process, args=(i,)) +# thread_list.append(thread) +# thread.start() +# +# for thread in thread_list: +# thread.join() +# +# +# if __name__ == "__main__": +# # 实时识别 +# # process(0) +# # 录音识别 +# process_rec(0) +# # process_multithread(20) \ No newline at end of file diff --git a/backend/middleware/tmp_6ca5f922df2512e5252e6f02522622a9.mp3 b/backend/middleware/tmp_6ca5f922df2512e5252e6f02522622a9.mp3 new file mode 100644 index 0000000..21eee5d Binary files /dev/null and b/backend/middleware/tmp_6ca5f922df2512e5252e6f02522622a9.mp3 differ diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100755 index 0000000..2baa941 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,72 @@ +import pytest +import asyncio +from typing import Generator, AsyncGenerator +from unittest.mock import Mock, AsyncMock +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from backend.common.model import Base + +# 测试数据库配置 +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + +@pytest.fixture(scope="session") +def event_loop(): + """创建事件循环""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope="session") +async def test_engine(): + """创建测试数据库引擎""" + engine = create_async_engine(TEST_DATABASE_URL, echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + +@pytest.fixture +async def test_db_session(test_engine) -> AsyncGenerator[AsyncSession, None]: + """创建测试数据库会话""" + async_session = sessionmaker( + test_engine, class_=AsyncSession, expire_on_commit=False + ) + async with async_session() as session: + yield session + +@pytest.fixture +def mock_current_user(): + """创建模拟的当前用户""" + user = Mock() + user.id = 12345 + user.openid = "test_openid_12345" + user.nickname = "TestUser" + return user + +@pytest.fixture +def mock_request_with_user(mock_current_user): + """创建包含用户信息的模拟请求""" + request = Mock() + request.user = mock_current_user + return request + +@pytest.fixture +def sample_user_data(): + """示例用户数据""" + return { + "openid": "test_openid_12345", + "session_key": "test_session_key", + "nickname": "TestUser" + } + +@pytest.fixture +def sample_order_data(): + """示例订单数据""" + return { + "user_id": 12345, + "order_type": "purchase", + "amount_cents": 1000, # 10元 + "amount_times": 100, # 100次 + "status": "pending" + } \ No newline at end of file diff --git a/backend/tests/test_ad_share_service.py b/backend/tests/test_ad_share_service.py new file mode 100755 index 0000000..58adc9b --- /dev/null +++ b/backend/tests/test_ad_share_service.py @@ -0,0 +1,119 @@ +import pytest +from unittest.mock import patch, AsyncMock, MagicMock +from backend.app.admin.service.ad_share_service import AdShareService +from backend.common.exception import errors + + +class TestAdShareService: + + @pytest.fixture + def mock_redis_client(self): + """创建模拟的Redis客户端""" + with patch('backend.app.admin.service.ad_share_service.redis_client') as mock_redis: + mock_redis.ping = AsyncMock(return_value=True) + mock_redis.get = AsyncMock(return_value=None) + mock_redis.incr = AsyncMock(return_value=1) + mock_redis.decr = AsyncMock(return_value=0) + mock_redis.expire = AsyncMock(return_value=True) + yield mock_redis + + def test_get_redis_key(self): + """测试Redis key生成""" + from datetime import datetime + test_date = "2024-01-15" + key = AdShareService._get_redis_key(12345, "ad", test_date) + assert key == "user:12345:ad:count:2024-01-15" + + # 测试默认日期 + with patch('backend.app.admin.service.ad_share_service.datetime') as mock_datetime: + mock_datetime.now.return_value.strftime.return_value = "2024-01-16" + key = AdShareService._get_redis_key(12345, "share") + assert key == "user:12345:share:count:2024-01-16" + + @pytest.mark.asyncio + async def test_check_daily_limit_not_exceeded(self, mock_redis_client): + """测试未超过每日限制""" + mock_redis_client.get.return_value = "3" # 当前3次,限制5次 + + result = await AdShareService._check_daily_limit(12345, "ad", 5) + assert result is False # 未超过限制 + + @pytest.mark.asyncio + async def test_check_daily_limit_exceeded(self, mock_redis_client): + """测试超过每日限制""" + mock_redis_client.get.return_value = "5" # 当前5次,限制5次 + + result = await AdShareService._check_daily_limit(12345, "ad", 5) + assert result is True # 超过限制 + + @pytest.mark.asyncio + async def test_grant_times_by_ad_success(self, mock_redis_client, mock_current_user): + """测试广告奖励成功""" + with patch('backend.database.db.async_db_session') as mock_db_session: + mock_context = AsyncMock() + mock_db_session.begin.return_value = mock_context + mock_context.__aenter__.return_value = AsyncMock() + + with patch('backend.app.admin.crud.user_account_crud.user_account_dao') as mock_account_dao, \ + patch('backend.app.admin.crud.usage_log_crud.usage_log_dao') as mock_log_dao: + mock_account_dao.update_balance_atomic.return_value = True + mock_account = AsyncMock() + mock_account.balance = 10 + mock_account_dao.get_by_user_id.return_value = mock_account + mock_log_dao.add.return_value = None + + # 执行测试 + await AdShareService.grant_times_by_ad(mock_current_user.id) + + # 验证调用 + mock_redis_client.incr.assert_called_once() + mock_account_dao.update_balance_atomic.assert_called_once() + mock_log_dao.add.assert_called_once() + + @pytest.mark.asyncio + async def test_grant_times_by_ad_limit_exceeded(self, mock_redis_client, mock_current_user): + """测试广告奖励超过限制""" + mock_redis_client.get.return_value = "5" # 已达到限制 + + with pytest.raises(errors.ForbiddenError) as exc_info: + await AdShareService.grant_times_by_ad(mock_current_user.id) + + assert "今日广告观看次数已达上限" in str(exc_info.value.msg) + + @pytest.mark.asyncio + async def test_grant_times_by_share_success(self, mock_redis_client, mock_current_user): + """测试分享奖励成功""" + with patch('backend.database.db.async_db_session') as mock_db_session: + mock_context = AsyncMock() + mock_db_session.begin.return_value = mock_context + mock_context.__aenter__.return_value = AsyncMock() + + with patch('backend.app.admin.crud.user_account_crud.user_account_dao') as mock_account_dao, \ + patch('backend.app.admin.crud.usage_log_crud.usage_log_dao') as mock_log_dao: + mock_account_dao.update_balance_atomic.return_value = True + mock_account = AsyncMock() + mock_account.balance = 5 + mock_account_dao.get_by_user_id.return_value = mock_account + mock_log_dao.add.return_value = None + + # 执行测试 + await AdShareService.grant_times_by_share(mock_current_user.id) + + # 验证调用 + mock_redis_client.incr.assert_called_once() + mock_account_dao.update_balance_atomic.assert_called_once() + mock_log_dao.add.assert_called_once() + + @pytest.mark.asyncio + async def test_get_daily_stats(self, mock_redis_client): + """测试获取每日统计""" + mock_redis_client.get.side_effect = ["3", "1"] # 广告3次,分享1次 + + stats = await AdShareService.get_daily_stats(12345) + + assert stats["ad_count"] == 3 + assert stats["ad_limit"] == 5 + assert stats["share_count"] == 1 + assert stats["share_limit"] == 3 + assert stats["can_watch_ad"] is True + assert stats["can_share"] is True \ No newline at end of file diff --git a/backend/tests/test_audit_log_service.py b/backend/tests/test_audit_log_service.py new file mode 100755 index 0000000..ef755ec --- /dev/null +++ b/backend/tests/test_audit_log_service.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +审计日志服务测试 +""" +import pytest +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.admin.service.audit_log_service import audit_log_service +from backend.app.admin.schema.audit_log import CreateAuditLogParam +from backend.database.db import async_db_session + + +class TestAuditLogService: + """审计日志服务测试类""" + + async def test_get_user_recognition_history(self): + """测试获取用户识别历史记录功能""" + # 准备测试数据 + user_id = 12345 + + # 调用服务方法 + history_items, total = await audit_log_service.get_user_recognition_history(user_id=user_id) + + # 验证结果 + assert isinstance(history_items, list) + assert isinstance(total, int) + + # 验证返回的数据结构 + if history_items: + item = history_items[0] + assert hasattr(item, 'thumbnail_id') + assert hasattr(item, 'created_time') + assert hasattr(item, 'dict_level') + + async def test_get_user_recognition_statistics(self): + """测试获取用户识别统计信息功能""" + # 准备测试数据 + user_id = 12345 + + # 调用服务方法 + total_count, today_count = await audit_log_service.get_user_recognition_statistics(user_id=user_id) + + # 验证结果 + assert isinstance(total_count, int) + assert isinstance(today_count, int) + assert total_count >= 0 + assert today_count >= 0 + assert total_count >= today_count + + +if __name__ == '__main__': + pytest.main() \ No newline at end of file diff --git a/backend/tests/test_coupon_service.py b/backend/tests/test_coupon_service.py new file mode 100755 index 0000000..f191d1c --- /dev/null +++ b/backend/tests/test_coupon_service.py @@ -0,0 +1,163 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from datetime import datetime, timedelta + +from backend.app.admin.service.coupon_service import CouponService +from backend.app.admin.model.coupon import Coupon, CouponUsage + + +@pytest.mark.asyncio +async def test_generate_unique_code(): + """ + 测试生成唯一兑换码 + """ + code1 = CouponService.generate_unique_code() + code2 = CouponService.generate_unique_code() + + # 检查长度 + assert len(code1) == 6 + assert len(code2) == 6 + + # 检查是否为大写字母和数字的组合 + for char in code1: + assert char.isupper() or char.isdigit() + + # 检查是否不包含敏感字符 + assert '0' not in code1 + assert 'O' not in code1 + assert 'I' not in code1 + assert '1' not in code1 + + +@pytest.mark.asyncio +async def test_create_coupon(): + """ + 测试创建单个兑换券 + """ + with patch('backend.app.admin.service.coupon_service.async_db_session') as mock_session: + # 模拟数据库会话 + mock_db = AsyncMock() + mock_session.begin.return_value.__aenter__.return_value = mock_db + + # 模拟coupon_dao + with patch('backend.app.admin.service.coupon_service.coupon_dao') as mock_dao: + # 模拟get_by_code返回None(确保代码唯一性) + mock_dao.get_by_code.return_value = None + + # 模拟create_coupon返回值 + mock_coupon = MagicMock(spec=Coupon) + mock_coupon.id = 1 + mock_coupon.code = "ABC123" + mock_coupon.duration = 30 + mock_dao.create_coupon.return_value = mock_coupon + + # 调用被测试的方法 + result = await CouponService.create_coupon(30, 30) + + # 验证结果 + assert result.id == 1 + assert result.code == "ABC123" + assert result.duration == 30 + + # 验证调用 + mock_dao.get_by_code.assert_called() + mock_dao.create_coupon.assert_called_once() + + +@pytest.mark.asyncio +async def test_batch_create_coupons(): + """ + 测试批量创建兑换券 + """ + with patch('backend.app.admin.service.coupon_service.async_db_session') as mock_session: + # 模拟数据库会话 + mock_db = AsyncMock() + mock_session.begin.return_value.__aenter__.return_value = mock_db + + # 模拟coupon_dao + with patch('backend.app.admin.service.coupon_service.coupon_dao') as mock_dao: + # 模拟get_by_code返回None(确保代码唯一性) + mock_dao.get_by_code.return_value = None + + # 模拟create_coupons返回值 + mock_coupons = [] + for i in range(5): + mock_coupon = MagicMock(spec=Coupon) + mock_coupon.id = i + 1 + mock_coupon.code = f"CODE0{i}" + mock_coupon.duration = 30 + mock_coupons.append(mock_coupon) + + mock_dao.create_coupons.return_value = mock_coupons + + # 调用被测试的方法 + result = await CouponService.batch_create_coupons(5, 30, 30) + + # 验证结果 + assert len(result) == 5 + for i, coupon in enumerate(result): + assert coupon.id == i + 1 + assert coupon.code == f"CODE0{i}" + assert coupon.duration == 30 + + # 验证调用 + mock_dao.create_coupons.assert_called_once() + + +@pytest.mark.asyncio +async def test_redeem_coupon_success(): + """ + 测试成功兑换兑换券 + """ + with patch('backend.app.admin.service.coupon_service.async_db_session') as mock_session: + # 模拟数据库会话 + mock_db = AsyncMock() + mock_session.begin.return_value.__aenter__.return_value = mock_db + + # 模拟coupon_dao + with patch('backend.app.admin.service.coupon_service.coupon_dao') as mock_dao: + # 模拟未使用的兑换券 + mock_coupon = MagicMock(spec=Coupon) + mock_coupon.id = 1 + mock_coupon.code = "ABC123" + mock_coupon.duration = 30 + mock_dao.get_unused_coupon_by_code.return_value = mock_coupon + + # 模拟标记为已使用成功 + mock_dao.mark_as_used.return_value = True + + # 调用被测试的方法 + result = await CouponService.redeem_coupon("ABC123", 1) + + # 验证结果 + assert result['code'] == "ABC123" + assert result['duration'] == 30 + assert 'used_at' in result + + # 验证调用 + mock_dao.get_unused_coupon_by_code.assert_called_once_with(mock_db, "ABC123") + mock_dao.mark_as_used.assert_called_once_with(mock_db, 1, 1, 30) + + +@pytest.mark.asyncio +async def test_redeem_coupon_invalid(): + """ + 测试兑换无效兑换券 + """ + with patch('backend.app.admin.service.coupon_service.async_db_session') as mock_session: + # 模拟数据库会话 + mock_db = AsyncMock() + mock_session.begin.return_value.__aenter__.return_value = mock_db + + # 模拟coupon_dao + with patch('backend.app.admin.service.coupon_service.coupon_dao') as mock_dao: + # 模拟未使用的兑换券不存在 + mock_dao.get_unused_coupon_by_code.return_value = None + + # 调用被测试的方法并期望抛出异常 + with pytest.raises(Exception): + await CouponService.redeem_coupon("INVALID", 1) + + # 验证调用 + mock_dao.get_unused_coupon_by_code.assert_called_once_with(mock_db, "INVALID") + mock_dao.mark_as_used.assert_not_called() \ No newline at end of file diff --git a/backend/tests/test_dict_service.py b/backend/tests/test_dict_service.py new file mode 100755 index 0000000..0540bc0 --- /dev/null +++ b/backend/tests/test_dict_service.py @@ -0,0 +1,173 @@ +import pytest +from unittest.mock import AsyncMock, Mock + +from backend.app.admin.service.dict_service import dict_service +from backend.app.admin.model.dict import DictionaryEntry +from backend.app.admin.schema.dict import ( + WordMetaData, Sense, Definition, Example, CrossReference, + Frequency, Pronunciation, DictWordResponse +) +from backend.common.exception import errors + + +class TestDictService: + @pytest.mark.asyncio + async def test_get_word_info_success(self, monkeypatch): + """测试成功获取单词信息""" + # 模拟数据 + mock_word_metadata = WordMetaData( + headword="apple", + pronunciations=Pronunciation( + uk_ipa="ˈæpəl", + uk_audio="media/english/breProns/brelasdeapple.mp3", + us_audio="media/english/ameProns/apple1.mp3" + ), + frequency=Frequency( + level_tag="●●●", + spoken_tag="S2", + written_tag="W3" + ), + senses=[ + Sense( + id="apple__1", + number="1", + definitions=[ + Definition( + cn="苹果", + en="a hard round fruit that has red, light green, or yellow skin and is white inside", + related_words=["fruit", "red", "green", "yellow", "skin", "inside"] + ) + ], + examples=[ + Example( + cn="烤猪肉配苹果酱汁", + en="roast pork and apple sauce", + audio="media/english/exaProns/p008-001671985.mp3" + ) + ], + cross_references=[ + CrossReference( + text="→ 4 See picture of见图", + show_id="img3241", + sense_id="apple__1", + image_filename="ldoce4188jpg" + ) + ] + ) + ] + ) + + mock_entry = DictionaryEntry( + id=1, + word="apple", + definition="苹果", + details=mock_word_metadata + ) + + # 模拟数据库会话和DAO + mock_db_session = AsyncMock() + mock_dict_dao = AsyncMock() + mock_dict_dao.get_by_word.return_value = mock_entry + + # 替换模块中的依赖 + monkeypatch.setattr("backend.app.admin.service.dict_service.async_db_session", mock_db_session) + monkeypatch.setattr("backend.app.admin.service.dict_service.dict_dao", mock_dict_dao) + + # 模拟 async_db_session 的上下文管理器 + mock_db_session.return_value.__aenter__.return_value = mock_db_session + mock_db_session.return_value.__aexit__.return_value = None + + # 执行测试 + result = await dict_service.get_word_info("apple") + + # 验证结果 + assert isinstance(result, DictWordResponse) + assert len(result.senses) == 1 + assert result.senses[0].id == "apple__1" + assert result.senses[0].number == "1" + assert len(result.senses[0].definitions) == 1 + assert result.senses[0].definitions[0].cn == "苹果" + assert result.frequency.level_tag == "●●●" + assert result.pronunciations.uk_ipa == "ˈæpəl" + + # 验证调用 + mock_dict_dao.get_by_word.assert_called_once_with(mock_db_session, "apple") + + @pytest.mark.asyncio + async def test_get_word_info_not_found(self, monkeypatch): + """测试单词不存在的情况""" + # 模拟数据库会话和DAO + mock_db_session = AsyncMock() + mock_dict_dao = AsyncMock() + mock_dict_dao.get_by_word.return_value = None + + # 替换模块中的依赖 + monkeypatch.setattr("backend.app.admin.service.dict_service.async_db_session", mock_db_session) + monkeypatch.setattr("backend.app.admin.service.dict_service.dict_dao", mock_dict_dao) + + # 模拟 async_db_session 的上下文管理器 + mock_db_session.return_value.__aenter__.return_value = mock_db_session + mock_db_session.return_value.__aexit__.return_value = None + + # 执行测试并验证异常 + with pytest.raises(errors.NotFoundError): + await dict_service.get_word_info("nonexistent") + + mock_dict_dao.get_by_word.assert_called_once_with(mock_db_session, "nonexistent") + + @pytest.mark.asyncio + async def test_get_word_info_empty_word(self): + """测试空单词的情况""" + with pytest.raises(errors.ForbiddenError): + await dict_service.get_word_info("") + + with pytest.raises(errors.ForbiddenError): + await dict_service.get_word_info(" ") + + @pytest.mark.asyncio + async def test_check_word_exists_true(self, monkeypatch): + """测试单词存在检查返回True""" + mock_entry = DictionaryEntry(id=1, word="test", definition="测试") + + # 模拟数据库会话和DAO + mock_db_session = AsyncMock() + mock_dict_dao = AsyncMock() + mock_dict_dao.get_by_word.return_value = mock_entry + + # 替换模块中的依赖 + monkeypatch.setattr("backend.app.admin.service.dict_service.async_db_session", mock_db_session) + monkeypatch.setattr("backend.app.admin.service.dict_service.dict_dao", mock_dict_dao) + + # 模拟 async_db_session 的上下文管理器 + mock_db_session.return_value.__aenter__.return_value = mock_db_session + mock_db_session.return_value.__aexit__.return_value = None + + # 执行测试 + result = await dict_service.check_word_exists("test") + + # 验证结果 + assert result is True + mock_dict_dao.get_by_word.assert_called_once_with(mock_db_session, "test") + + @pytest.mark.asyncio + async def test_check_word_exists_false(self, monkeypatch): + """测试单词不存在检查返回False""" + # 模拟数据库会话和DAO + mock_db_session = AsyncMock() + mock_dict_dao = AsyncMock() + mock_dict_dao.get_by_word.return_value = None + + # 替换模块中的依赖 + monkeypatch.setattr("backend.app.admin.service.dict_service.async_db_session", mock_db_session) + monkeypatch.setattr("backend.app.admin.service.dict_service.dict_dao", mock_dict_dao) + + # 模拟 async_db_session 的上下文管理器 + mock_db_session.return_value.__aenter__.return_value = mock_db_session + mock_db_session.return_value.__aexit__.return_value = None + + # 执行测试 + result = await dict_service.check_word_exists("nonexistent") + + # 验证结果 + assert result is False + mock_dict_dao.get_by_word.assert_called_once_with(mock_db_session, "nonexistent") \ No newline at end of file diff --git a/backend/tests/test_dict_service_caching.py b/backend/tests/test_dict_service_caching.py new file mode 100755 index 0000000..a27c0c6 --- /dev/null +++ b/backend/tests/test_dict_service_caching.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +测试字典服务中的Redis缓存机制 +""" + +import pytest +import asyncio +from unittest.mock import AsyncMock, patch + +from backend.app.admin.service.dict_service import DictService +from backend.app.admin.model.dict import DictionaryEntry +from backend.app.admin.schema.dict import WordMetaData + + +@pytest.mark.asyncio +async def test_get_linked_word_with_cache(): + """测试get_linked_word方法的缓存机制""" + + # 创建测试数据 + word = "apples" + linked_word = "apple" + + # 测试缓存未命中时从数据库获取 + with patch('backend.app.admin.service.dict_service.DictService._get_linked_word_from_db') as mock_db_method: + mock_db_method.return_value = linked_word + + result = await DictService.get_linked_word(word) + + # 验证结果正确 + assert result == linked_word + # 验证调用了数据库方法 + mock_db_method.assert_called_once_with(word) + + +@pytest.mark.asyncio +async def test_get_linked_word_cache_hit(): + """测试缓存命中情况""" + + word = "apples" + linked_word = "apple" + + # 模拟Redis缓存命中 + with patch('backend.app.admin.service.dict_service.DictService._get_linked_word_from_cache') as mock_cache_method: + mock_cache_method.return_value = linked_word + + # 模拟数据库方法 + with patch('backend.app.admin.service.dict_service.DictService._get_linked_word_from_db') as mock_db_method: + result = await DictService.get_linked_word(word) + + # 验证结果正确 + assert result == linked_word + # 验证调用了缓存方法 + mock_cache_method.assert_called_once_with(word) + # 验证没有调用数据库方法 + mock_db_method.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_linked_word_no_link(): + """测试没有链接单词的情况""" + + word = "simple" + + # 模拟数据库中没有链接 + with patch('backend.app.admin.service.dict_service.DictService._get_linked_word_from_db') as mock_db_method: + mock_db_method.return_value = word # 返回原单词表示没有链接 + + result = await DictService.get_linked_word(word) + + # 验证返回了原单词 + assert result == word + + +@pytest.mark.asyncio +async def test_get_linked_word_from_db(): + """测试从数据库获取链接单词""" + + word = "apples" + linked_word = "apple" + + # 创建模拟的字典条目 + mock_entry = AsyncMock() + mock_entry.details = WordMetaData( + ref_link=[f"LINK={linked_word}"], + dict_list=[] # 空数组表示需要检查ref_link + ) + + # 模拟数据库查询 + with patch('backend.app.admin.service.dict_service.dict_dao.get_by_word') as mock_get_by_word: + mock_get_by_word.return_value = mock_entry + + result = await DictService._get_linked_word_from_db(word) + + # 验证结果正确 + assert result == linked_word + + +@pytest.mark.asyncio +async def test_get_linked_word_from_db_no_entry(): + """测试数据库中没有条目的情况""" + + word = "nonexistent" + + # 模拟数据库查询返回None + with patch('backend.app.admin.service.dict_service.dict_dao.get_by_word') as mock_get_by_word: + mock_get_by_word.return_value = None + + result = await DictService._get_linked_word_from_db(word) + + # 验证返回None + assert result is None + + +@pytest.mark.asyncio +async def test_cache_methods(): + """测试缓存读写方法""" + + word = "apples" + linked_word = "apple" + + # 测试设置缓存 + with patch('backend.database.redis.redis_client.setex') as mock_setex: + await DictService._set_linked_word_to_cache(word, linked_word) + mock_setex.assert_called_once() + + # 测试缓存未命中 + with patch('backend.database.redis.redis_client.get') as mock_get: + mock_get.return_value = None + result = await DictService._get_linked_word_from_cache(word) + assert result is None + + # 测试缓存命中 + with patch('backend.database.redis.redis_client.get') as mock_get: + mock_get.return_value = linked_word + result = await DictService._get_linked_word_from_cache(word) + assert result == linked_word + + # 测试缓存中存储的是"None" + with patch('backend.database.redis.redis_client.get') as mock_get: + mock_get.return_value = "None" + result = await DictService._get_linked_word_from_cache(word) + assert result == word # 应该返回原单词 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/backend/tests/test_dict_service_optimization.py b/backend/tests/test_dict_service_optimization.py new file mode 100755 index 0000000..1a1e650 --- /dev/null +++ b/backend/tests/test_dict_service_optimization.py @@ -0,0 +1,224 @@ +import pytest +from unittest.mock import AsyncMock + +from backend.app.admin.service.dict_service import dict_service +from backend.app.admin.model.dict import DictionaryEntry +from backend.app.admin.schema.dict import WordMetaData, DictEntry +from backend.common.exception import errors + + +class TestDictServiceOptimization: + @pytest.mark.asyncio + async def test_get_word_info_with_ref_link(self, monkeypatch): + """测试当dict_list为空且存在ref_link时,能够正确提取并查询引用单词""" + # 模拟原始条目(dict_list为空,但有ref_link) + mock_word_metadata = WordMetaData( + ref_link=["LINK=cucumber"], + dict_list=[], # 空数组 + source_dict="Longman Dictionary of Contemporary English 5++" + ) + + mock_entry = DictionaryEntry( + id=1, + word="pickle", + definition="泡菜", + details=mock_word_metadata + ) + + # 模拟引用的条目(实际要返回的内容) + mock_referenced_dict_entry = DictEntry( + headword="cucumber", + part_of_speech="noun", + senses=[{ + "id": "cucumber__1", + "definitions": [{ + "cn": "黄瓜", + "en": "a long thin green vegetable that has a cool slightly bitter taste" + }] + }] + ) + + mock_referenced_metadata = WordMetaData( + ref_link=None, + dict_list=[mock_referenced_dict_entry], + source_dict="Longman Dictionary of Contemporary English 5++" + ) + + mock_referenced_entry = DictionaryEntry( + id=2, + word="cucumber", + definition="黄瓜", + details=mock_referenced_metadata + ) + + # 模拟数据库会话和DAO + mock_db_session = AsyncMock() + mock_dict_dao = AsyncMock() + + # 设置DAO的返回值 + # 第一次调用get_by_word返回原始条目 + # 第二次调用get_by_word返回引用条目 + mock_dict_dao.get_by_word.side_effect = [mock_entry, mock_referenced_entry] + + # 替换模块中的依赖 + monkeypatch.setattr("backend.app.admin.service.dict_service.async_db_session", mock_db_session) + monkeypatch.setattr("backend.app.admin.service.dict_service.dict_dao", mock_dict_dao) + + # 模拟 async_db_session 的上下文管理器 + mock_db_session.return_value.__aenter__.return_value = mock_db_session + mock_db_session.return_value.__aexit__.return_value = None + + # 执行测试 + result = await dict_service.get_word_info("pickle") + + # 验证结果 + assert isinstance(result, dict_service.DictWordResponse) + # 应该返回引用单词的信息,而不是原始单词的信息 + assert len(result.dict_list) > 0 + assert result.dict_list[0].part_of_speech == "noun" + + # 验证调用次数 + assert mock_dict_dao.get_by_word.call_count == 2 + # 验证第一次调用参数 + mock_dict_dao.get_by_word.assert_any_call(mock_db_session, "pickle") + # 验证第二次调用参数 + mock_dict_dao.get_by_word.assert_any_call(mock_db_session, "cucumber") + + @pytest.mark.asyncio + async def test_get_word_info_without_ref_link(self, monkeypatch): + """测试当dict_list为空但没有ref_link时,返回原始条目""" + # 模拟原始条目(dict_list为空,也没有ref_link) + mock_word_metadata = WordMetaData( + ref_link=None, # 没有ref_link + dict_list=[], # 空数组 + source_dict="Longman Dictionary of Contemporary English 5++" + ) + + mock_entry = DictionaryEntry( + id=1, + word="pickle", + definition="泡菜", + details=mock_word_metadata + ) + + # 模拟数据库会话和DAO + mock_db_session = AsyncMock() + mock_dict_dao = AsyncMock() + mock_dict_dao.get_by_word.return_value = mock_entry + + # 替换模块中的依赖 + monkeypatch.setattr("backend.app.admin.service.dict_service.async_db_session", mock_db_session) + monkeypatch.setattr("backend.app.admin.service.dict_service.dict_dao", mock_dict_dao) + + # 模拟 async_db_session 的上下文管理器 + mock_db_session.return_value.__aenter__.return_value = mock_db_session + mock_db_session.return_value.__aexit__.return_value = None + + # 执行测试 + result = await dict_service.get_word_info("pickle") + + # 验证结果 + assert isinstance(result, dict_service.DictWordResponse) + # 应该返回空的dict_list + assert len(result.dict_list) == 0 + + # 验证调用次数 + assert mock_dict_dao.get_by_word.call_count == 1 + mock_dict_dao.get_by_word.assert_called_once_with(mock_db_session, "pickle") + + @pytest.mark.asyncio + async def test_get_word_info_with_non_link_ref(self, monkeypatch): + """测试当ref_link不以LINK=开头时,返回原始条目""" + # 模拟原始条目(dict_list为空,但ref_link不以LINK=开头) + mock_word_metadata = WordMetaData( + ref_link=["OTHER_REF=something"], # 不以LINK=开头 + dict_list=[], # 空数组 + source_dict="Longman Dictionary of Contemporary English 5++" + ) + + mock_entry = DictionaryEntry( + id=1, + word="pickle", + definition="泡菜", + details=mock_word_metadata + ) + + # 模拟数据库会话和DAO + mock_db_session = AsyncMock() + mock_dict_dao = AsyncMock() + mock_dict_dao.get_by_word.return_value = mock_entry + + # 替换模块中的依赖 + monkeypatch.setattr("backend.app.admin.service.dict_service.async_db_session", mock_db_session) + monkeypatch.setattr("backend.app.admin.service.dict_service.dict_dao", mock_dict_dao) + + # 模拟 async_db_session 的上下文管理器 + mock_db_session.return_value.__aenter__.return_value = mock_db_session + mock_db_session.return_value.__aexit__.return_value = None + + # 执行测试 + result = await dict_service.get_word_info("pickle") + + # 验证结果 + assert isinstance(result, dict_service.DictWordResponse) + # 应该返回空的dict_list + assert len(result.dict_list) == 0 + + # 验证调用次数 + assert mock_dict_dao.get_by_word.call_count == 1 + mock_dict_dao.get_by_word.assert_called_once_with(mock_db_session, "pickle") + + @pytest.mark.asyncio + async def test_get_word_info_with_valid_details(self, monkeypatch): + """测试当dict_list不为空时,直接返回原始条目""" + # 模拟原始条目(dict_list不为空) + mock_dict_entry = DictEntry( + headword="pickle", + part_of_speech="noun", + senses=[{ + "id": "pickle__1", + "definitions": [{ + "cn": "泡菜", + "en": "cucumber preserved in vinegar or brine" + }] + }] + ) + + mock_word_metadata = WordMetaData( + ref_link=["LINK=cucumber"], + dict_list=[mock_dict_entry], # 非空数组 + source_dict="Longman Dictionary of Contemporary English 5++" + ) + + mock_entry = DictionaryEntry( + id=1, + word="pickle", + definition="泡菜", + details=mock_word_metadata + ) + + # 模拟数据库会话和DAO + mock_db_session = AsyncMock() + mock_dict_dao = AsyncMock() + mock_dict_dao.get_by_word.return_value = mock_entry + + # 替换模块中的依赖 + monkeypatch.setattr("backend.app.admin.service.dict_service.async_db_session", mock_db_session) + monkeypatch.setattr("backend.app.admin.service.dict_service.dict_dao", mock_dict_dao) + + # 模拟 async_db_session 的上下文管理器 + mock_db_session.return_value.__aenter__.return_value = mock_db_session + mock_db_session.return_value.__aexit__.return_value = None + + # 执行测试 + result = await dict_service.get_word_info("pickle") + + # 验证结果 + assert isinstance(result, dict_service.DictWordResponse) + # 应该返回原始单词的信息 + assert len(result.dict_list) > 0 + assert result.dict_list[0].part_of_speech == "noun" + + # 验证调用次数 + assert mock_dict_dao.get_by_word.call_count == 1 + mock_dict_dao.get_by_word.assert_called_once_with(mock_db_session, "pickle") \ No newline at end of file diff --git a/backend/tests/test_notification_service.py b/backend/tests/test_notification_service.py new file mode 100755 index 0000000..6fa20eb --- /dev/null +++ b/backend/tests/test_notification_service.py @@ -0,0 +1,183 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from datetime import datetime + +from backend.app.admin.service.notification_service import NotificationService +from backend.app.admin.model.notification import Notification, UserNotification + + +@pytest.mark.asyncio +async def test_create_notification(): + """ + 测试创建消息通知 + """ + with patch('backend.app.admin.service.notification_service.async_db_session') as mock_session: + # 模拟数据库会话 + mock_db = AsyncMock() + mock_session.begin.return_value.__aenter__.return_value = mock_db + + # 模拟notification_dao + with patch('backend.app.admin.service.notification_service.notification_dao') as mock_dao: + # 模拟create_notification返回值 + mock_notification = MagicMock(spec=Notification) + mock_notification.id = 1 + mock_notification.title = "Test Notification" + mock_notification.content = "This is a test notification" + mock_notification.image_url = None + mock_dao.create_notification.return_value = mock_notification + + # 调用被测试的方法 + result = await NotificationService.create_notification( + "Test Notification", + "This is a test notification" + ) + + # 验证结果 + assert result.id == 1 + assert result.title == "Test Notification" + assert result.content == "This is a test notification" + + # 验证调用 + mock_dao.create_notification.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_notification_to_user(): + """ + 测试发送通知给指定用户 + """ + with patch('backend.app.admin.service.notification_service.async_db_session') as mock_session: + # 模拟数据库会话 + mock_db = AsyncMock() + mock_session.begin.return_value.__aenter__.return_value = mock_db + + # 模拟notification_dao和user_notification_dao + with patch('backend.app.admin.service.notification_service.notification_dao') as mock_notification_dao, \ + patch('backend.app.admin.service.notification_service.user_notification_dao') as mock_user_notification_dao: + + # 模拟通知存在 + mock_notification = MagicMock(spec=Notification) + mock_notification.id = 1 + mock_notification_dao.get.return_value = mock_notification + + # 模拟create_user_notification返回值 + mock_user_notification = MagicMock(spec=UserNotification) + mock_user_notification.id = 1 + mock_user_notification.notification_id = 1 + mock_user_notification.user_id = 1 + mock_user_notification_dao.create_user_notification.return_value = mock_user_notification + + # 调用被测试的方法 + result = await NotificationService.send_notification_to_user(1, 1) + + # 验证结果 + assert result.id == 1 + assert result.notification_id == 1 + assert result.user_id == 1 + + # 验证调用 + mock_notification_dao.get.assert_called_once_with(mock_db, 1) + mock_user_notification_dao.create_user_notification.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_user_notifications(): + """ + 测试获取用户通知列表 + """ + with patch('backend.app.admin.service.notification_service.async_db_session') as mock_session: + # 模拟数据库会话 + mock_db = AsyncMock() + mock_session.return_value.__aenter__.return_value = mock_db + + # 模拟notification_dao和user_notification_dao + with patch('backend.app.admin.service.notification_service.notification_dao') as mock_notification_dao, \ + patch('backend.app.admin.service.notification_service.user_notification_dao') as mock_user_notification_dao: + + # 模拟用户通知列表 + mock_user_notification = MagicMock(spec=UserNotification) + mock_user_notification.id = 1 + mock_user_notification.notification_id = 1 + mock_user_notification.is_read = False + mock_user_notification.received_at = datetime.now() + mock_user_notification.read_at = None + mock_user_notifications = [mock_user_notification] + mock_user_notification_dao.get_user_notifications.return_value = mock_user_notifications + + # 模拟通知详情 + mock_notification = MagicMock(spec=Notification) + mock_notification.id = 1 + mock_notification.title = "Test Notification" + mock_notification.content = "This is a test notification" + mock_notification.image_url = None + mock_notification_dao.get.return_value = mock_notification + + # 调用被测试的方法 + result = await NotificationService.get_user_notifications(1) + + # 验证结果 + assert len(result) == 1 + assert result[0]['id'] == 1 + assert result[0]['title'] == "Test Notification" + assert result[0]['content'] == "This is a test notification" + + # 验证调用 + mock_user_notification_dao.get_user_notifications.assert_called_once_with(mock_db, 1, 100) + mock_notification_dao.get.assert_called_once_with(mock_db, 1) + + +@pytest.mark.asyncio +async def test_get_unread_count(): + """ + 测试获取用户未读通知数量 + """ + with patch('backend.app.admin.service.notification_service.async_db_session') as mock_session: + # 模拟数据库会话 + mock_db = AsyncMock() + mock_session.return_value.__aenter__.return_value = mock_db + + # 模拟user_notification_dao + with patch('backend.app.admin.service.notification_service.user_notification_dao') as mock_user_notification_dao: + # 模拟未读数量 + mock_user_notification_dao.get_unread_count.return_value = 5 + + # 调用被测试的方法 + result = await NotificationService.get_unread_count(1) + + # 验证结果 + assert result == 5 + + # 验证调用 + mock_user_notification_dao.get_unread_count.assert_called_once_with(mock_db, 1) + + +@pytest.mark.asyncio +async def test_mark_notification_as_read(): + """ + 测试标记通知为已读 + """ + with patch('backend.app.admin.service.notification_service.async_db_session') as mock_session: + # 模拟数据库会话 + mock_db = AsyncMock() + mock_session.begin.return_value.__aenter__.return_value = mock_db + + # 模拟user_notification_dao + with patch('backend.app.admin.service.notification_service.user_notification_dao') as mock_user_notification_dao: + # 模拟用户通知 + mock_user_notification = MagicMock(spec=UserNotification) + mock_user_notification.id = 1 + mock_user_notification.user_id = 1 + mock_user_notification_dao.get_user_notification_by_id.return_value = mock_user_notification + + # 模拟标记为已读成功 + mock_user_notification_dao.mark_as_read.return_value = True + + # 调用被测试的方法 + result = await NotificationService.mark_notification_as_read(1, 1) + + # 验证结果 + assert result is True + + # 验证调用 + mock_user_notification_dao.get_user_notification_by_id.assert_called_once_with(mock_db, 1) + mock_user_notification_dao.mark_as_read.assert_called_once_with(mock_db, 1) \ No newline at end of file diff --git a/backend/tests/test_usage_routes.py b/backend/tests/test_usage_routes.py new file mode 100755 index 0000000..86ae385 --- /dev/null +++ b/backend/tests/test_usage_routes.py @@ -0,0 +1,98 @@ +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, AsyncMock +from backend.main import app + +client = TestClient(app) + + +class TestUsageRoutes: + + @pytest.fixture + def auth_headers(self): + """认证头""" + return {"Authorization": "Bearer test_token"} + + @pytest.fixture + def mock_user(self): + """模拟用户""" + return {"id": 12345, "openid": "test_openid"} + + def test_purchase_times_success(self, auth_headers): + """测试充值接口成功""" + purchase_data = {"amount_cents": 1000} + + with patch('backend.app.admin.service.usage_service.UsageService.purchase_times') as mock_purchase: + mock_purchase.return_value = None + + response = client.post("/usage/purchase", json=purchase_data, headers=auth_headers) + + assert response.status_code == 200 + assert response.json() == {"msg": "充值成功"} + + def test_purchase_times_invalid_amount(self, auth_headers): + """测试充值金额无效""" + purchase_data = {"amount_cents": 0} # 无效金额 + + response = client.post("/usage/purchase", json=purchase_data, headers=auth_headers) + + assert response.status_code == 422 # 验证错误 + + def test_subscribe_success(self, auth_headers): + """测试订阅接口成功""" + subscribe_data = {"plan": "monthly"} + + with patch('backend.app.admin.service.subscription_service.SubscriptionService.subscribe') as mock_subscribe: + mock_subscribe.return_value = None + + response = client.post("/usage/subscribe", json=subscribe_data, headers=auth_headers) + + assert response.status_code == 200 + assert response.json() == {"msg": "订阅成功"} + + def test_subscribe_invalid_plan(self, auth_headers): + """测试订阅计划无效""" + subscribe_data = {"plan": "invalid_plan"} + + response = client.post("/usage/subscribe", json=subscribe_data, headers=auth_headers) + + assert response.status_code == 422 # 验证错误 + + def test_ad_grant_success(self, auth_headers): + """测试广告奖励接口成功""" + with patch('backend.app.admin.service.ad_share_service.AdShareService.grant_times_by_ad') as mock_ad: + mock_ad.return_value = None + + response = client.post("/usage/ad", headers=auth_headers) + + assert response.status_code == 200 + assert response.json() == {"msg": "已通过广告获得次数"} + + def test_share_grant_success(self, auth_headers): + """测试分享奖励接口成功""" + with patch('backend.app.admin.service.ad_share_service.AdShareService.grant_times_by_share') as mock_share: + mock_share.return_value = None + + response = client.post("/usage/share", headers=auth_headers) + + assert response.status_code == 200 + assert response.json() == {"msg": "已通过分享获得次数"} + + def test_daily_stats_success(self, auth_headers): + """测试每日统计接口成功""" + mock_stats = { + "ad_count": 2, + "ad_limit": 5, + "share_count": 1, + "share_limit": 3, + "can_watch_ad": True, + "can_share": True + } + + with patch('backend.app.admin.service.ad_share_service.AdShareService.get_daily_stats') as mock_stats_func: + mock_stats_func.return_value = mock_stats + + response = client.get("/usage/daily-stats", headers=auth_headers) + + assert response.status_code == 200 + assert response.json()["stats"] == mock_stats \ No newline at end of file diff --git a/backend/tests/test_usage_service.py b/backend/tests/test_usage_service.py new file mode 100755 index 0000000..a5a10d9 --- /dev/null +++ b/backend/tests/test_usage_service.py @@ -0,0 +1,117 @@ +import pytest +from decimal import Decimal +from unittest.mock import patch, AsyncMock + +from backend.app.admin.schema.usage import PurchaseRequest +from backend.app.admin.service.usage_service import UsageService + + +class TestUsageService: + + def test_calculate_purchase_times_safe(self): + """测试安全的充值次数计算""" + # 基础测试 + assert UsageService.calculate_purchase_times_safe(100) == 10 # 1元 = 10次 + assert UsageService.calculate_purchase_times_safe(1000) == 100 # 10元 = 100次 + assert UsageService.calculate_purchase_times_safe(1100) == 121 # 11元 = 110次 + 11次 = 121次 + assert UsageService.calculate_purchase_times_safe(2000) == 220 # 20元 = 200次 + 20次 = 220次 + assert UsageService.calculate_purchase_times_safe(10000) == 1100 # 100元 = 1000次 + 100次 = 1100次 + + # 边界测试 + with pytest.raises(ValueError): + UsageService.calculate_purchase_times_safe(0) + + with pytest.raises(ValueError): + UsageService.calculate_purchase_times_safe(-100) + + # 大额测试 + assert UsageService.calculate_purchase_times_safe(100000) == 20000 # 1000元,最多100%优惠 + + @pytest.mark.asyncio + async def test_get_user_account_new(self, test_db_session): + """测试获取新用户账户""" + with patch('backend.database.db.async_db_session') as mock_session: + mock_session.return_value.__aenter__.return_value = test_db_session + + account = await UsageService.get_user_account(99999) + assert account.user_id == 99999 + assert account.balance == 0 + + @pytest.mark.asyncio + async def test_purchase_times_success(self, test_db_session, mock_current_user): + """测试充值成功""" + with patch('backend.database.db.async_db_session') as mock_db_session: + # 模拟数据库会话上下文管理器 + mock_context = AsyncMock() + mock_context.__aenter__.return_value = test_db_session + mock_db_session.begin.return_value = mock_context + + # 模拟DAO + with patch('backend.app.admin.crud.user_account_crud.user_account_dao') as mock_account_dao, \ + patch('backend.app.admin.crud.order_crud.order_dao') as mock_order_dao, \ + patch('backend.app.admin.crud.usage_log_crud.usage_log_dao') as mock_log_dao: + # 设置模拟返回值 + mock_account = AsyncMock() + mock_account.id = 1 + mock_account.balance = 0 + mock_account_dao.get_by_user_id.return_value = mock_account + mock_account_dao.add.return_value = None + mock_account_dao.update.return_value = None + + mock_order = AsyncMock() + mock_order.id = 100 + mock_order_dao.add.return_value = None + mock_order_dao.update.return_value = None + + mock_log_dao.add.return_value = None + + # 执行测试 + request = PurchaseRequest(amount_cents=1000) # 10元 + await UsageService.purchase_times(mock_current_user.id, request) + + # 验证调用 + mock_order_dao.add.assert_called_once() + mock_account_dao.update.assert_called_once() + mock_log_dao.add.assert_called_once() + + @pytest.mark.asyncio + async def test_use_times_atomic_success(self, test_db_session, mock_current_user): + """测试原子性扣减次数成功""" + with patch('backend.database.db.async_db_session') as mock_db_session: + mock_context = AsyncMock() + mock_context.__aenter__.return_value = test_db_session + mock_db_session.begin.return_value = mock_context + + with patch('backend.app.admin.crud.user_account_crud.user_account_dao') as mock_account_dao, \ + patch('backend.app.admin.crud.usage_log_crud.usage_log_dao') as mock_log_dao: + # 设置模拟返回值 + mock_account = AsyncMock() + mock_account.id = 1 + mock_account.balance = 100 + mock_account_dao.get_by_user_id.return_value = mock_account + mock_account_dao.update.return_value = None + + mock_log_dao.add.return_value = None + + # 执行测试 + await UsageService.use_times_atomic(mock_current_user.id, 5) + + # 验证调用 + mock_account_dao.update.assert_called_once() + mock_log_dao.add.assert_called_once() + + @pytest.mark.asyncio + async def test_use_times_atomic_insufficient_balance(self, test_db_session, mock_current_user): + """测试余额不足时的原子性扣减""" + with patch('backend.database.db.async_db_session') as mock_db_session: + mock_context = AsyncMock() + mock_context.__aenter__.return_value = test_db_session + mock_db_session.begin.return_value = mock_context + + with patch('backend.app.admin.crud.user_account_crud.user_account_dao') as mock_account_dao: + # 模拟更新操作返回0行(余额不足) + mock_account_dao.update_balance_atomic.return_value = False + + # 执行测试并验证异常 + with pytest.raises(Exception): # 具体异常类型根据实际实现调整 + await UsageService.use_times_atomic(mock_current_user.id, 1000) \ No newline at end of file diff --git a/backend/tests/test_wxpay_utils.py b/backend/tests/test_wxpay_utils.py new file mode 100755 index 0000000..ef873d6 --- /dev/null +++ b/backend/tests/test_wxpay_utils.py @@ -0,0 +1,74 @@ +from unittest.mock import patch + +import pytest + +from backend.utils.wx_pay import WxPayUtils + + +class TestWxPayUtils: + + def test_parse_payment_result_success(self): + """测试解析微信支付结果成功""" + xml_data = ''' + + + + + + + + + + + + + 1 + + + + + + ''' + + result = WxPayUtils.parse_payment_result(xml_data.encode('utf-8')) + + assert result['return_code'] == 'SUCCESS' + assert result['return_msg'] == 'OK' + assert result['out_trade_no'] == '1217752501201407033233368018' + assert result['total_fee'] == '1' + + def test_parse_payment_result_invalid_xml(self): + """测试解析无效XML""" + invalid_xml = "invalid xml data" + + with pytest.raises(ValueError): + WxPayUtils.parse_payment_result(invalid_xml.encode('utf-8')) + + def test_verify_signature_success(self): + """测试签名验证成功""" + test_data = { + 'appid': 'wx2421b1c4370ec43b', + 'mch_id': '10000100', + 'nonce_str': 'IITRi8IbLcXMzfDj', + 'sign': '7926A6C08D48E637E835817332F13E9B' + } + api_key = "your_api_key_here" + + # 注意:这里需要根据实际的签名算法来验证 + # 为了测试,我们模拟返回True + with patch.object(WxPayUtils, 'verify_signature', return_value=True): + result = WxPayUtils.verify_signature(test_data, api_key) + assert result is True + + def test_generate_signature(self): + """测试生成签名""" + params = { + 'appid': 'wx2421b1c4370ec43b', + 'mch_id': '10000100', + 'nonce_str': 'IITRi8IbLcXMzfDj' + } + api_key = "your_api_key_here" + + signature = WxPayUtils.generate_signature(params, api_key) + assert isinstance(signature, str) + assert len(signature) == 32 # MD5签名长度 \ No newline at end of file diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100755 index 0000000..6f7b18c --- /dev/null +++ b/backend/utils/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/utils/_await.py b/backend/utils/_await.py new file mode 100755 index 0000000..e1512ef --- /dev/null +++ b/backend/utils/_await.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import asyncio +import atexit +import threading +import weakref + +from typing import Awaitable, Callable, TypeVar + +T = TypeVar('T') + + +class _TaskRunner: + """A task runner that runs an asyncio event loop on a background thread.""" + + def __init__(self): + self.__loop: asyncio.AbstractEventLoop | None = None + self.__thread: threading.Thread | None = None + self.__lock = threading.Lock() + atexit.register(self.close) + + def close(self): + """关闭事件循环""" + if self.__loop: + self.__loop.stop() + + def _target(self): + """后台线程目标""" + loop = self.__loop + try: + loop.run_forever() + finally: + loop.close() + + def run(self, coro): + """在后台线程上同步运行协程""" + with self.__lock: + name = f'{threading.current_thread().name} - runner' + if self.__loop is None: + self.__loop = asyncio.new_event_loop() + self.__thread = threading.Thread(target=self._target, daemon=True, name=name) + self.__thread.start() + fut = asyncio.run_coroutine_threadsafe(coro, self.__loop) + return fut.result(None) + + +_runner_map = weakref.WeakValueDictionary() + + +def run_await(coro: Callable[..., Awaitable[T]]) -> Callable[..., T]: + """将协程包装在一个函数中,该函数会阻塞,直到它执行完为止""" + + def wrapped(*args, **kwargs): + name = threading.current_thread().name + inner = coro(*args, **kwargs) + try: + # 如果当前此线程中正在运行循环 + # 使用任务运行程序 + asyncio.get_running_loop() + if name not in _runner_map: + _runner_map[name] = _TaskRunner() + return _runner_map[name].run(inner) + except RuntimeError: + # 如果没有,请创建一个新的事件循环 + loop = asyncio.get_event_loop() + return loop.run_until_complete(inner) + + wrapped.__doc__ = coro.__doc__ + return wrapped diff --git a/backend/utils/demo_site.py b/backend/utils/demo_site.py new file mode 100755 index 0000000..2dd1a26 --- /dev/null +++ b/backend/utils/demo_site.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import Request + +from backend.common.exception import errors +from backend.core.conf import settings + + +async def demo_site(request: Request): + """演示站点""" + + method = request.method + path = request.url.path + if ( + settings.DEMO_MODE + and method != 'GET' + and method != 'OPTIONS' + and (method, path) not in settings.DEMO_MODE_EXCLUDE + ): + raise errors.ForbiddenError(msg='演示环境下禁止执行此操作') diff --git a/backend/utils/generate_coupons.py b/backend/utils/generate_coupons.py new file mode 100755 index 0000000..f74eef5 --- /dev/null +++ b/backend/utils/generate_coupons.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +兑换券批量生成脚本 +""" + +import argparse +import asyncio +import sys +from typing import List + +# 添加项目路径到sys.path +sys.path.append('.') + +from backend.app.admin.service.coupon_service import CouponService +from backend.app.admin.model.coupon import Coupon + + +async def generate_coupons(count: int, duration: int, expires_days: int = None) -> List[Coupon]: + """ + 批量生成兑换券 + + :param count: 生成数量 + :param duration: 兑换时长(分钟) + :param expires_days: 过期天数(可选) + :return: 生成的兑换券列表 + """ + print(f"开始生成 {count} 个兑换券,每个兑换券时长为 {duration} 分钟") + if expires_days: + print(f"兑换券将在 {expires_days} 天后过期") + + coupons = await CouponService.batch_create_coupons(count, duration, expires_days) + + print(f"成功生成 {len(coupons)} 个兑换券:") + for coupon in coupons: + print(f" - 兑换码: {coupon.code}, 时长: {coupon.duration} 分钟") + + return coupons + + +def main(): + parser = argparse.ArgumentParser(description='批量生成兑换券') + parser.add_argument('-c', '--count', type=int, required=True, help='生成数量') + parser.add_argument('-d', '--duration', type=int, required=True, help='兑换时长(分钟)') + parser.add_argument('-e', '--expires', type=int, help='过期天数(可选)') + + args = parser.parse_args() + + # 验证参数 + if args.count <= 0 or args.count > 10000: + print("错误: 兑换券数量必须在1-10000之间") + return 1 + + if args.duration <= 0: + print("错误: 兑换时长必须大于0") + return 1 + + if args.expires and args.expires <= 0: + print("错误: 过期天数必须大于0") + return 1 + + # 运行异步函数 + try: + asyncio.run(generate_coupons(args.count, args.duration, args.expires)) + print("兑换券生成完成!") + return 0 + except Exception as e: + print(f"生成兑换券时发生错误: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/backend/utils/health_check.py b/backend/utils/health_check.py new file mode 100755 index 0000000..ac57086 --- /dev/null +++ b/backend/utils/health_check.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from math import ceil + +from fastapi import FastAPI, Request, Response +from fastapi.routing import APIRoute + +from backend.common.exception import errors + + +def ensure_unique_route_names(app: FastAPI) -> None: + """ + 检查路由名称是否唯一 + + :param app: + :return: + """ + temp_routes = set() + for route in app.routes: + if isinstance(route, APIRoute): + if route.name in temp_routes: + raise ValueError(f'Non-unique route name: {route.name}') + temp_routes.add(route.name) + + +async def http_limit_callback(request: Request, response: Response, expire: int): + """ + 请求限制时的默认回调函数 + + :param request: + :param response: + :param expire: 剩余毫秒 + :return: + """ + expires = ceil(expire / 1000) + raise errors.HTTPError(code=429, msg='请求过于频繁,请稍后重试', headers={'Retry-After': str(expires)}) diff --git a/backend/utils/import_parse.py b/backend/utils/import_parse.py new file mode 100644 index 0000000..22769ac --- /dev/null +++ b/backend/utils/import_parse.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import importlib +import inspect + +from functools import lru_cache +from typing import Any, Type, TypeVar + +from backend.common.exception import errors +from backend.common.log import log + +T = TypeVar('T') + + +@lru_cache(maxsize=512) +def import_module_cached(module_path: str) -> Any: + """ + 缓存导入模块 + + :param module_path: 模块路径 + :return: + """ + return importlib.import_module(module_path) + + +def dynamic_import_data_model(module_path: str) -> Type[T]: + """ + 动态导入数据模型 + + :param module_path: 模块路径,格式为 'module_path.class_name' + :return: + """ + try: + module_path, class_name = module_path.rsplit('.', 1) + module = import_module_cached(module_path) + return getattr(module, class_name) + except Exception as e: + log.error(f'动态导入数据模型失败:{e}') + raise errors.ServerError(msg='数据模型列动态解析失败,请联系系统超级管理员') + + +def get_model_objects(module_path: str) -> list[type] | None: + """ + 获取模型对象 + + :param module_path: 模块路径 + :return: + """ + try: + module = import_module_cached(module_path) + except ModuleNotFoundError: + log.warning(f'模块 {module_path} 中不包含模型对象') + return None + except Exception as e: + raise e + + classes = [] + + for name, obj in inspect.getmembers(module): + if inspect.isclass(obj) and module_path in obj.__module__: + classes.append(obj) + + return classes diff --git a/backend/utils/json_encoder.py b/backend/utils/json_encoder.py new file mode 100755 index 0000000..21c5596 --- /dev/null +++ b/backend/utils/json_encoder.py @@ -0,0 +1,25 @@ +import json +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any +from pydantic import BaseModel + +class CustomJSONEncoder(json.JSONEncoder): + """处理 Pydantic 模型、枚举和日期等特殊类型的 JSON 编码器""" + def default(self, obj: Any) -> Any: + if isinstance(obj, BaseModel): + return obj.dict() + if isinstance(obj, Enum): + return obj.value + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, Path): + return str(obj) + if hasattr(obj, '__dict__'): + return obj.__dict__ + return super().default(obj) + +def jsonable_encoder(data: Any) -> Any: + """将对象转换为 JSON 兼容格式""" + return json.loads(json.dumps(data, cls=CustomJSONEncoder)) \ No newline at end of file diff --git a/backend/utils/openapi.py b/backend/utils/openapi.py new file mode 100755 index 0000000..78779e0 --- /dev/null +++ b/backend/utils/openapi.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from fastapi import FastAPI +from fastapi.routing import APIRoute + + +def simplify_operation_ids(app: FastAPI) -> None: + """ + 简化操作 ID,以便生成的客户端具有更简单的 api 函数名称 + + :param app: + :return: + """ + for route in app.routes: + if isinstance(route, APIRoute): + route.operation_id = route.name diff --git a/backend/utils/preload_dict_links.py b/backend/utils/preload_dict_links.py new file mode 100755 index 0000000..3e7de18 --- /dev/null +++ b/backend/utils/preload_dict_links.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +预加载字典链接关系到Redis缓存的脚本 +该脚本扫描数据库中的所有字典条目,找出具有ref_link的条目, +并将word -> linked_word的映射关系存储到Redis中,以提高查询性能。 +""" + +import asyncio +import logging +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.admin.model.dict import DictionaryEntry +from backend.app.admin.crud.dict_crud import dict_dao +from backend.database.db import async_db_session +from backend.database.redis import redis_client +from backend.app.admin.service.dict_service import DictService + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +async def preload_dict_links_to_redis(batch_size: int = 1000, clear_cache: bool = True) -> None: + """ + 预加载字典链接关系到Redis缓存 + + Args: + batch_size: 每批处理的条目数量 + clear_cache: 是否在预加载前清除现有缓存 + """ + logger.info("开始预加载字典链接关系到Redis缓存...") + + try: + # 初始化Redis连接 + await redis_client.open() + logger.info("Redis连接成功") + + # 如果需要,先清除现有缓存 + if clear_cache: + logger.info("清除现有字典链接缓存...") + await clear_dict_links_cache() + + # 初始化数据库连接 + processed_count = 0 + cached_count = 0 + error_count = 0 + + async with async_db_session() as db: + # 分批查询所有可能具有ref_link的条目 + offset = 0 + while True: + # 查询一批条目 + query = select(DictionaryEntry).offset(offset).limit(batch_size) + result = await db.execute(query) + entries = result.scalars().all() + + # 如果没有更多条目,退出循环 + if not entries: + break + + # 处理这批条目 + for entry in entries: + try: + if entry.details and not entry.details.dict_list and entry.details.ref_link: + # 检查ref_link内容 + ref_links = entry.details.ref_link + if ref_links: + # 获取第一个链接 + first_link = ref_links[0] + # 检查是否包含 "LINK=" 前缀 + if isinstance(first_link, str) and first_link.startswith("LINK="): + # 截取 "LINK=" 后的单词 + referenced_word = first_link[5:] # 去掉 "LINK=" 前缀 + if referenced_word: + # 将映射关系存入Redis缓存 + cache_key = f"{DictService.WORD_LINK_CACHE_PREFIX}{entry.word.lower()}" + try: + await redis_client.setex( + cache_key, + DictService.CACHE_EXPIRE_TIME, + referenced_word + ) + cached_count += 1 + if cached_count % 1000 == 0: + logger.info(f"已缓存 {cached_count} 个链接关系...") + except Exception as e: + logger.warning(f"缓存 {entry.word} 失败: {e}") + error_count += 1 + except Exception as e: + logger.warning(f"处理条目 {entry.word} 时出错: {e}") + error_count += 1 + + processed_count += len(entries) + logger.info(f"已处理 {processed_count} 个字典条目...") + + # 如果这一批少于batch_size,说明已经处理完所有条目 + if len(entries) < batch_size: + break + + # 更新offset以获取下一批 + offset += batch_size + + logger.info(f"预加载完成!") + logger.info(f" 总共处理了 {processed_count} 个字典条目") + logger.info(f" 成功缓存了 {cached_count} 个链接关系") + logger.info(f" 处理失败 {error_count} 个条目") + + except Exception as e: + logger.error(f"预加载过程中发生错误: {e}") + raise + finally: + # 关闭数据库连接等清理工作可以在这里进行 + pass + + +async def clear_dict_links_cache() -> None: + """ + 清除Redis中所有的字典链接缓存 + """ + logger.info("开始清除字典链接缓存...") + + try: + # 初始化Redis连接 + await redis_client.open() + + # 删除所有以指定前缀开头的键 + deleted_count = 0 + async for key in redis_client.scan_iter(match=f"{DictService.WORD_LINK_CACHE_PREFIX}*"): + await redis_client.delete(key) + deleted_count += 1 + if deleted_count % 1000 == 0: + logger.info(f"已删除 {deleted_count} 个缓存条目...") + + logger.info(f"清除完成! 总共删除了 {deleted_count} 个缓存条目") + + except Exception as e: + logger.error(f"清除缓存过程中发生错误: {e}") + raise + + +async def show_cache_stats() -> None: + """ + 显示缓存统计信息 + """ + logger.info("开始统计字典链接缓存信息...") + + try: + # 初始化Redis连接 + await redis_client.open() + + # 统计缓存条目数量 + count = 0 + async for key in redis_client.scan_iter(match=f"{DictService.WORD_LINK_CACHE_PREFIX}*"): + count += 1 + + logger.info(f"当前字典链接缓存条目数量: {count}") + + except Exception as e: + logger.error(f"统计缓存信息时发生错误: {e}") + raise + + +def main(): + """主函数""" + import argparse + + parser = argparse.ArgumentParser(description="字典链接关系预加载工具") + parser.add_argument( + "action", + choices=["preload", "clear", "stats"], + help="执行操作: preload(预加载), clear(清除缓存), stats(显示统计信息)" + ) + parser.add_argument( + "--batch-size", + type=int, + default=1000, + help="每批处理的条目数量 (默认: 1000)" + ) + parser.add_argument( + "--no-clear", + action="store_true", + help="预加载时不清除现有缓存" + ) + + args = parser.parse_args() + + if args.action == "preload": + asyncio.run(preload_dict_links_to_redis( + batch_size=args.batch_size, + clear_cache=not args.no_clear + )) + elif args.action == "clear": + asyncio.run(clear_dict_links_cache()) + elif args.action == "stats": + asyncio.run(show_cache_stats()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/utils/re_verify.py b/backend/utils/re_verify.py new file mode 100755 index 0000000..8254138 --- /dev/null +++ b/backend/utils/re_verify.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import re + + +def search_string(pattern: str, text: str) -> bool: + """ + 全字段正则匹配 + + :param pattern: 正则表达式模式 + :param text: 待匹配的文本 + :return: + """ + if not pattern or not text: + return False + + result = re.search(pattern, text) + return result is not None + + +def match_string(pattern: str, text: str) -> bool: + """ + 从字段开头正则匹配 + + :param pattern: 正则表达式模式 + :param text: 待匹配的文本 + :return: + """ + if not pattern or not text: + return False + + result = re.match(pattern, text) + return result is not None + + +def is_phone(text: str) -> bool: + """ + 检查手机号码格式 + + :param text: 待检查的手机号码 + :return: + """ + if not text: + return False + + phone_pattern = r'^1[3-9]\d{9}$' + return match_string(phone_pattern, text) diff --git a/backend/utils/serializers.py b/backend/utils/serializers.py new file mode 100755 index 0000000..8c5e6e5 --- /dev/null +++ b/backend/utils/serializers.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from decimal import Decimal +from typing import Any, Sequence, TypeVar + +from fastapi.encoders import decimal_encoder +from msgspec import json +from sqlalchemy import Row, RowMapping +from sqlalchemy.orm import ColumnProperty, SynonymProperty, class_mapper +from starlette.responses import JSONResponse + +RowData = Row | RowMapping | Any + +R = TypeVar('R', bound=RowData) + + +def select_columns_serialize(row: R) -> dict[str, Any]: + """ + 序列化 SQLAlchemy 查询表的列,不包含关联列 + + :param row: SQLAlchemy 查询结果行 + :return: + """ + result = {} + for column in row.__table__.columns.keys(): + value = getattr(row, column) + if isinstance(value, Decimal): + value = decimal_encoder(value) + result[column] = value + return result + + +def select_list_serialize(row: Sequence[R]) -> list[dict[str, Any]]: + """ + 序列化 SQLAlchemy 查询列表 + + :param row: SQLAlchemy 查询结果列表 + :return: + """ + return [select_columns_serialize(item) for item in row] + + +def select_as_dict(row: R, use_alias: bool = False) -> dict[str, Any]: + """ + 将 SQLAlchemy 查询结果转换为字典,可以包含关联数据 + + :param row: SQLAlchemy 查询结果行 + :param use_alias: 是否使用别名作为列名 + :return: + """ + if not use_alias: + result = row.__dict__ + if '_sa_instance_state' in result: + del result['_sa_instance_state'] + else: + result = {} + mapper = class_mapper(row.__class__) # type: ignore + for prop in mapper.iterate_properties: + if isinstance(prop, (ColumnProperty, SynonymProperty)): + key = prop.key + result[key] = getattr(row, key) + + return result + + +class MsgSpecJSONResponse(JSONResponse): + """ + 使用高性能的 msgspec 库将数据序列化为 JSON 的响应类 + """ + + def render(self, content: Any) -> bytes: + return json.encode(content) diff --git a/backend/utils/snowflake.py b/backend/utils/snowflake.py new file mode 100755 index 0000000..5a74caa --- /dev/null +++ b/backend/utils/snowflake.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import time + +from dataclasses import dataclass + +from backend.common.dataclasses import SnowflakeInfo +from backend.common.exception import errors +from backend.core.conf import settings + + +@dataclass(frozen=True) +class SnowflakeConfig: + """雪花算法配置类""" + + # 位分配 + WORKER_ID_BITS: int = 5 + DATACENTER_ID_BITS: int = 5 + SEQUENCE_BITS: int = 12 + + # 最大值 + MAX_WORKER_ID: int = (1 << WORKER_ID_BITS) - 1 # 31 + MAX_DATACENTER_ID: int = (1 << DATACENTER_ID_BITS) - 1 # 31 + SEQUENCE_MASK: int = (1 << SEQUENCE_BITS) - 1 # 4095 + + # 位移偏移 + WORKER_ID_SHIFT: int = SEQUENCE_BITS + DATACENTER_ID_SHIFT: int = SEQUENCE_BITS + WORKER_ID_BITS + TIMESTAMP_LEFT_SHIFT: int = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS + + # 元年时间戳 + EPOCH: int = 1262275200000 + + # 默认值 + DEFAULT_DATACENTER_ID: int = 1 + DEFAULT_WORKER_ID: int = 0 + DEFAULT_SEQUENCE: int = 0 + + +class Snowflake: + """雪花算法类""" + + def __init__( + self, + cluster_id: int = SnowflakeConfig.DEFAULT_DATACENTER_ID, + node_id: int = SnowflakeConfig.DEFAULT_WORKER_ID, + sequence: int = SnowflakeConfig.DEFAULT_SEQUENCE, + ): + """ + 初始化雪花算法生成器 + + :param cluster_id: 集群 ID (0-7) + :param node_id: 节点 ID (0-7) + :param sequence: 起始序列号 + """ + if cluster_id < 0 or cluster_id > SnowflakeConfig.MAX_DATACENTER_ID: + raise errors.ForbiddenError(msg=f'集群编号必须在 0-{SnowflakeConfig.MAX_DATACENTER_ID} 之间') + if node_id < 0 or node_id > SnowflakeConfig.MAX_WORKER_ID: + raise errors.ForbiddenError(msg=f'节点编号必须在 0-{SnowflakeConfig.MAX_WORKER_ID} 之间') + + self.node_id = node_id + self.cluster_id = cluster_id + self.sequence = sequence + self.last_timestamp = -1 + + @staticmethod + def _current_millis() -> int: + """返回当前毫秒时间戳""" + return int(time.time() * 1000) + + def _next_millis(self, last_timestamp: int) -> int: + """ + 等待至下一毫秒 + + :param last_timestamp: 上次生成 ID 的时间戳 + :return: + """ + timestamp = self._current_millis() + while timestamp <= last_timestamp: + time.sleep((last_timestamp - timestamp + 1) / 1000.0) + timestamp = self._current_millis() + return timestamp + + def generate(self) -> int: + """生成雪花 ID""" + timestamp = self._current_millis() + + if timestamp < self.last_timestamp: + raise errors.ServerError(msg=f'系统时间倒退,拒绝生成 ID 直到 {self.last_timestamp}') + + if timestamp == self.last_timestamp: + self.sequence = (self.sequence + 1) & SnowflakeConfig.SEQUENCE_MASK + if self.sequence == 0: + timestamp = self._next_millis(self.last_timestamp) + else: + self.sequence = 0 + + self.last_timestamp = timestamp + + return ( + ((timestamp - SnowflakeConfig.EPOCH) << SnowflakeConfig.TIMESTAMP_LEFT_SHIFT) + | (self.cluster_id << SnowflakeConfig.DATACENTER_ID_SHIFT) + | (self.node_id << SnowflakeConfig.WORKER_ID_SHIFT) + | self.sequence + ) + + @staticmethod + def parse_id(snowflake_id: int) -> SnowflakeInfo: + """ + 解析雪花 ID,获取其包含的详细信息 + + :param snowflake_id: 雪花ID + :return: + """ + timestamp = (snowflake_id >> SnowflakeConfig.TIMESTAMP_LEFT_SHIFT) + SnowflakeConfig.EPOCH + cluster_id = (snowflake_id >> SnowflakeConfig.DATACENTER_ID_SHIFT) & SnowflakeConfig.MAX_DATACENTER_ID + node_id = (snowflake_id >> SnowflakeConfig.WORKER_ID_SHIFT) & SnowflakeConfig.MAX_WORKER_ID + sequence = snowflake_id & SnowflakeConfig.SEQUENCE_MASK + + return SnowflakeInfo( + timestamp=timestamp, + datetime=time.strftime(settings.DATETIME_FORMAT, time.localtime(timestamp / 1000)), + cluster_id=cluster_id, + node_id=node_id, + sequence=sequence, + ) + + +snowflake = Snowflake() diff --git a/backend/utils/timezone.py b/backend/utils/timezone.py new file mode 100755 index 0000000..7f59499 --- /dev/null +++ b/backend/utils/timezone.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import zoneinfo + +from datetime import datetime +from datetime import timezone as datetime_timezone + +from backend.core.conf import settings + + +class TimeZone: + def __init__(self) -> None: + """初始化时区转换器""" + self.tz_info = zoneinfo.ZoneInfo(settings.DATETIME_TIMEZONE) + + def now(self) -> datetime: + """获取当前时区时间""" + return datetime.now(self.tz_info) + + def from_datetime(self, t: datetime) -> datetime: + """ + 将 datetime 对象转换为当前时区时间 + + :param t: 需要转换的 datetime 对象 + :return: + """ + return t.astimezone(self.tz_info) + + def from_str(self, t_str: str, format_str: str = settings.DATETIME_FORMAT) -> datetime: + """ + 将时间字符串转换为当前时区的 datetime 对象 + + :param t_str: 时间字符串 + :param format_str: 时间格式字符串,默认为 settings.DATETIME_FORMAT + :return: + """ + return datetime.strptime(t_str, format_str).replace(tzinfo=self.tz_info) + + @staticmethod + def to_str(t: datetime, format_str: str = settings.DATETIME_FORMAT) -> str: + """ + 将 datetime 对象转换为指定格式的时间字符串 + + :param t: datetime 对象 + :param format_str: 时间格式字符串,默认为 settings.DATETIME_FORMAT + :return: + """ + return t.strftime(format_str) + + @staticmethod + def to_utc(t: datetime | int) -> datetime: + """ + 将 datetime 对象或时间戳转换为 UTC 时区时间 + + :param t: 需要转换的 datetime 对象或时间戳 + :return: + """ + if isinstance(t, datetime): + return t.astimezone(datetime_timezone.utc) + return datetime.fromtimestamp(t, tz=datetime_timezone.utc) + + +timezone: TimeZone = TimeZone() diff --git a/backend/utils/wx_pay.py b/backend/utils/wx_pay.py new file mode 100755 index 0000000..44b204b --- /dev/null +++ b/backend/utils/wx_pay.py @@ -0,0 +1,153 @@ +import xml.etree.ElementTree as ET +import hashlib +import hmac +from typing import Dict, Any, Union +import json + + +class WxPayUtils: + """微信支付工具类""" + + @staticmethod + def parse_payment_result(xml_data: Union[bytes, str]) -> Dict[str, Any]: + """ + 解析微信支付XML回调数据 + + Args: + xml_data: 微信支付回调的XML数据 + + Returns: + 解析后的字典数据 + """ + try: + # 处理bytes类型 + if isinstance(xml_data, bytes): + xml_str = xml_data.decode('utf-8') + else: + xml_str = xml_data + + # 解析XML + root = ET.fromstring(xml_str) + + # 转换为字典 + result = {} + for child in root: + result[child.tag] = child.text + + return result + + except ET.ParseError as e: + raise ValueError(f"XML解析失败: {str(e)}") + except Exception as e: + raise ValueError(f"解析微信支付回调数据失败: {str(e)}") + + @staticmethod + def verify_signature(data: Dict[str, Any], api_key: str) -> bool: + """ + 验证微信支付回调签名(V2版本) + + Args: + data: 微信支付回调数据字典 + api_key: 微信支付API密钥 + + Returns: + bool: 签名是否有效 + """ + try: + # 深拷贝数据 + data_copy = data.copy() + + # 获取签名 + sign = data_copy.pop('sign', None) + if not sign: + return False + + # 过滤空值和签名字段 + filtered_data = {k: str(v) for k, v in data_copy.items() if v is not None and k != 'sign'} + + # 按键名ASCII码排序 + sorted_keys = sorted(filtered_data.keys()) + + # 拼接字符串 + stringA = '&'.join(f"{key}={filtered_data[key]}" for key in sorted_keys) + stringSignTemp = f"{stringA}&key={api_key}" + + # 计算签名(MD5) + calculated_sign = hashlib.md5(stringSignTemp.encode('utf-8')).hexdigest().upper() + + # 比较签名 + return sign == calculated_sign + + except Exception as e: + try: + from backend.common.log import log as logger + logger.error(f"微信支付签名验证异常: {str(e)}") + except: + pass + return False + + @staticmethod + def verify_signature_v3(data: str, api_key: str, timestamp: str, nonce: str, signature: str) -> bool: + """ + 验证微信支付V3版本回调签名(HMAC-SHA256) + + Args: + data: 微信支付回调数据 + api_key: 微信支付API密钥 + timestamp: 时间戳 + nonce: 随机字符串 + signature: 签名 + + Returns: + bool: 签名是否有效 + """ + try: + # 构建签名字符串 + message = f"{timestamp}\n{nonce}\n{data}\n" + + # 使用HMAC-SHA256计算签名 + digest = hmac.new( + api_key.encode('utf-8'), + message.encode('utf-8'), + hashlib.sha256 + ).digest() + + calculated_signature = digest.hex() + + # 比较签名 + return signature == calculated_signature + + except Exception as e: + try: + from backend.common.log import log as logger + logger.error(f"微信支付V3签名验证异常: {str(e)}") + except: + pass + return False + + @staticmethod + def generate_signature(params: Dict[str, Any], api_key: str) -> str: + """ + 生成微信支付签名 + + Args: + params: 参数字典 + api_key: 微信支付API密钥 + + Returns: + str: 生成的签名 + """ + # 过滤空值 + filtered_params = {k: str(v) for k, v in params.items() if v is not None and v != ''} + + # 按键名ASCII码排序 + sorted_keys = sorted(filtered_params.keys()) + + # 拼接字符串 + stringA = '&'.join(f"{key}={filtered_params[key]}" for key in sorted_keys) + stringSignTemp = f"{stringA}&key={api_key}" + + # 计算签名(MD5) + return hashlib.md5(stringSignTemp.encode('utf-8')).hexdigest().upper() + +wx_pay_utils = WxPayUtils() \ No newline at end of file diff --git a/deploy/dict_cache_preload.md b/deploy/dict_cache_preload.md new file mode 100755 index 0000000..45b2e44 --- /dev/null +++ b/deploy/dict_cache_preload.md @@ -0,0 +1,77 @@ +# 字典链接缓存预加载指南 + +## 概述 + +由于字典条目数据(dict_entry)很少变动,为了提高系统性能,我们提供了预加载脚本将所有具有ref_link的条目预加载到Redis缓存中。这样可以避免在运行时重复查询数据库,显著提升处理速度。 + +## 首次部署 + +在首次部署系统时,建议运行预加载脚本来初始化Redis缓存: + +### Linux/macOS 系统 + +```bash +# 进入项目根目录 +cd /path/to/project + +# 运行预加载脚本 +./deploy/preload_dict_cache.sh +``` + +### Windows 系统 + +```cmd +# 进入项目根目录 +cd \path\to\project + +# 运行预加载脚本 +deploy\preload_dict_cache.bat +``` + +## 手动运行预加载脚本 + +您也可以直接使用Python运行预加载脚本: + +```bash +# 进入项目根目录 +cd /path/to/project + +# 查看帮助信息 +python backend/utils/preload_dict_links.py --help + +# 预加载所有链接关系(默认会清除现有缓存) +python backend/utils/preload_dict_links.py preload + +# 预加载所有链接关系(不清除现有缓存) +python backend/utils/preload_dict_links.py preload --no-clear + +# 清除所有链接缓存 +python backend/utils/preload_dict_links.py clear + +# 显示缓存统计信息 +python backend/utils/preload_dict_links.py stats +``` + +## 参数说明 + +- `--batch-size`: 每批处理的条目数量,默认为1000 +- `--no-clear`: 预加载时不清除现有缓存 + +## 定期维护 + +虽然字典数据很少变动,但建议定期运行预加载脚本以确保缓存数据与数据库保持同步: + +```bash +# 每周日凌晨2点运行预加载 +0 2 * * 0 cd /path/to/project && ./deploy/preload_dict_cache.sh +``` + +## 性能监控 + +您可以使用以下命令查看缓存统计信息: + +```bash +python backend/utils/preload_dict_links.py stats +``` + +这将显示当前缓存中的条目数量,帮助您监控缓存使用情况。 \ No newline at end of file diff --git a/deploy/docker-compose/.env.server b/deploy/docker-compose/.env.server new file mode 100755 index 0000000..cde0c56 --- /dev/null +++ b/deploy/docker-compose/.env.server @@ -0,0 +1,14 @@ +# Env: dev、pro +ENVIRONMENT='dev' +# MySQL +DATABASE_HOST='fsm_mysql' +DATABASE_PORT=3306 +DATABASE_USER='root' +DATABASE_PASSWORD='123456' +# Redis +REDIS_HOST='fsm_redis' +REDIS_PORT=6379 +REDIS_PASSWORD='' +REDIS_DATABASE=0 +# Token +TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk' diff --git a/deploy/docker-compose/docker-compose.yml b/deploy/docker-compose/docker-compose.yml new file mode 100755 index 0000000..3640e0a --- /dev/null +++ b/deploy/docker-compose/docker-compose.yml @@ -0,0 +1,86 @@ +services: + fsm_server: + build: + context: ../../ + dockerfile: Dockerfile + image: fsm_server:latest + container_name: fsm_server + restart: always + depends_on: + - fsm_mysql + - fsm_redis + volumes: + - fsm_static:/fsm/backend/static + networks: + - fsm_network + command: + - bash + - -c + - | + wait-for-it -s fsm_mysql:3306 -s fsm_redis:6379 -t 300 + supervisord -c /etc/supervisor/supervisord.conf + supervisorctl restart + + fsm_mysql: + image: mysql:8.0.29 + ports: + - "3306:3306" + container_name: fsm_mysql + restart: always + environment: + MYSQL_DATABASE: fsm + MYSQL_ROOT_PASSWORD: 123456 + TZ: Asia/Shanghai + volumes: + - fsm_mysql:/var/lib/mysql + networks: + - fsm_network + command: + --default-authentication-plugin=mysql_native_password + --character-set-server=utf8mb4 + --collation-server=utf8mb4_general_ci + --lower_case_table_names=1 + + fsm_redis: + image: redis:6.2.7 + ports: + - "6379:6379" + container_name: fsm_redis + restart: always + environment: + - TZ=Asia/Shanghai + volumes: + - fsm_redis:/var/lib/redis + networks: + - fsm_network + + fsm_nginx: + image: nginx:stable + ports: + - "8000:80" + container_name: fsm_nginx + restart: always + depends_on: + - fsm_server + volumes: + - ../nginx.conf:/etc/nginx/conf.d/default.conf:ro + - fsm_static:/www/fsm_server/backend/static + networks: + - fsm_network + +networks: + fsm_network: + name: fsm_network + driver: bridge + ipam: + driver: default + config: + - subnet: 172.10.10.0/24 + +volumes: + fsm_mysql: + name: fsm_mysql + fsm_redis: + name: fsm_redis + fsm_static: + name: fsm_static diff --git a/deploy/fastapi_server.conf b/deploy/fastapi_server.conf new file mode 100755 index 0000000..d6d05e5 --- /dev/null +++ b/deploy/fastapi_server.conf @@ -0,0 +1,9 @@ +[program:fastapi_server] +directory=/fsm +command=/usr/local/bin/gunicorn -c /fsm/deploy/gunicorn.conf.py main:app +user=root +autostart=true +autorestart=true +startretries=5 +redirect_stderr=true +stdout_logfile=/var/log/fastapi_server/fsm_server.log diff --git a/deploy/gunicorn.conf.py b/deploy/gunicorn.conf.py new file mode 100755 index 0000000..9099d10 --- /dev/null +++ b/deploy/gunicorn.conf.py @@ -0,0 +1,44 @@ +# fmt: off +# 监听内网端口 +bind = '0.0.0.0:8001' + +# 工作目录 +chdir = '/fsm/backend/' + +# 并行工作进程数 +workers = 1 + +# 监听队列 +backlog = 512 + +# 超时时间 +timeout = 120 + +# 设置守护进程,将进程交给 supervisor 管理;如果设置为 True 时,supervisor 启动日志为: +# gave up: fastapi_server entered FATAL state, too many start retries too quickly +# 则需要将此改为: False +daemon = False + +# 工作模式协程 +worker_class = 'uvicorn.workers.UvicornWorker' + +# 设置最大并发量 +worker_connections = 2000 + +# 设置进程文件目录 +pidfile = '/fsm/gunicorn.pid' + +# 设置访问日志和错误信息日志路径 +accesslog = '/var/log/fastapi_server/gunicorn_access.log' +errorlog = '/var/log/fastapi_server/gunicorn_error.log' + +# 设置这个值为true 才会把打印信息记录到错误日志里 +capture_output = True + +# 设置日志记录水平 +loglevel = 'debug' + +# python程序 +pythonpath = '/usr/local/lib/python3.10/site-packages' + +# 启动 gunicorn -c gunicorn.conf.py main:app diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100755 index 0000000..98a6278 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name 127.0.0.1; + + root /fsm; + + client_max_body_size 5M; + client_body_buffer_size 5M; + + gzip on; + gzip_comp_level 2; + gzip_types text/plain text/css text/javascript application/javascript application/x-javascript application/xml application/x-httpd-php image/jpeg image/gif image/png; + gzip_vary on; + + keepalive_timeout 300; + + location / { + proxy_pass http://fsm_server:8001; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + location /static { + alias /www/fsm_server/backend/static; + } +} diff --git a/deploy/preload_dict_cache.bat b/deploy/preload_dict_cache.bat new file mode 100755 index 0000000..5133daa --- /dev/null +++ b/deploy/preload_dict_cache.bat @@ -0,0 +1,36 @@ +@echo off +REM 首次部署时预加载字典链接缓存的脚本 (Windows版本) +REM 该脚本应在应用启动前运行 + +echo 开始预加载字典链接缓存... + +REM 检查是否在正确的目录 +if not exist "backend\utils\preload_dict_links.py" ( + echo 错误: 请在项目根目录运行此脚本 + exit /b 1 +) + +REM 激活虚拟环境(如果存在) +if exist "venv\Scripts\activate.bat" ( + call venv\Scripts\activate.bat + echo 已激活虚拟环境 +) else if exist ".venv\Scripts\activate.bat" ( + call .venv\Scripts\activate.bat + echo 已激活虚拟环境 +) + +REM 运行预加载脚本 +echo 正在运行预加载脚本... +python backend/utils/preload_dict_links.py preload --batch-size 2000 + +if %errorlevel% neq 0 ( + echo 预加载脚本执行失败 + exit /b %errorlevel% +) + +echo 字典链接缓存预加载完成! + +echo 显示缓存统计信息: +python backend/utils/preload_dict_links.py stats + +echo 部署预加载完成! \ No newline at end of file diff --git a/deploy/preload_dict_cache.sh b/deploy/preload_dict_cache.sh new file mode 100755 index 0000000..364b186 --- /dev/null +++ b/deploy/preload_dict_cache.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# 首次部署时预加载字典链接缓存的脚本 +# 该脚本应在应用启动前运行 + +set -e # 遇到错误时退出 + +echo "开始预加载字典链接缓存..." + +# 检查是否在正确的目录 +if [ ! -f "backend/utils/preload_dict_links.py" ]; then + echo "错误: 请在项目根目录运行此脚本" + exit 1 +fi + +# 激活虚拟环境(如果存在) +if [ -f "venv/bin/activate" ]; then + source venv/bin/activate + echo "已激活虚拟环境" +elif [ -f ".venv/bin/activate" ]; then + source .venv/bin/activate + echo "已激活虚拟环境" +fi + +# 运行预加载脚本 +echo "正在运行预加载脚本..." +python backend/utils/preload_dict_links.py preload --batch-size 2000 + +echo "字典链接缓存预加载完成!" + +echo "显示缓存统计信息:" +python backend/utils/preload_dict_links.py stats + +echo "部署预加载完成!" \ No newline at end of file diff --git a/deploy/supervisor.conf b/deploy/supervisor.conf new file mode 100755 index 0000000..484db7e --- /dev/null +++ b/deploy/supervisor.conf @@ -0,0 +1,155 @@ +; Sample supervisor config file. +; +; For more information on the config file, please see: +; http://supervisord.org/configuration.html +; +; Notes: +; - Shell expansion ("~" or "$HOME") is not supported. Environment +; variables can be expanded using this syntax: "%(ENV_HOME)s". +; - Quotes around values are not supported, except in the case of +; the environment= options as shown below. +; - Comments must have a leading space: "a=b ;comment" not "a=b;comment". +; - Command will be truncated if it looks like a config file comment, e.g. +; "command=bash -c 'foo ; bar'" will truncate to "command=bash -c 'foo ". +; +; Warning: +; Paths throughout this example file use /tmp because it is available on most +; systems. You will likely need to change these to locations more appropriate +; for your system. Some systems periodically delete older files in /tmp. +; Notably, if the socket file defined in the [unix_http_server] section below +; is deleted, supervisorctl will be unable to connect to supervisord. + +[unix_http_server] +file=/tmp/supervisor.sock ; the path to the socket file +;chmod=0700 ; socket file mode (default 0700) +;chown=nobody:nogroup ; socket file uid:gid owner +;username=user ; default is no username (open server) +;password=123 ; default is no password (open server) + +; Security Warning: +; The inet HTTP server is not enabled by default. The inet HTTP server is +; enabled by uncommenting the [inet_http_server] section below. The inet +; HTTP server is intended for use within a trusted environment only. It +; should only be bound to localhost or only accessible from within an +; isolated, trusted network. The inet HTTP server does not support any +; form of encryption. The inet HTTP server does not use authentication +; by default (see the username= and password= options to add authentication). +; Never expose the inet HTTP server to the public internet. + +;[inet_http_server] ; inet (TCP) server disabled by default +;port=127.0.0.1:9001 ; ip_address:port specifier, *:port for all iface +;username=user ; default is no username (open server) +;password=123 ; default is no password (open server) + +[supervisord] +logfile=/var/log/supervisor/supervisord.log ; main log file; default $CWD/supervisord.log +logfile_maxbytes=50MB ; max main logfile bytes b4 rotation; default 50MB +logfile_backups=10 ; # of main logfile backups; 0 means none, default 10 +loglevel=info ; log level; default info; others: debug,warn,trace +pidfile=/tmp/supervisord.pid ; supervisord pidfile; default supervisord.pid +nodaemon=true ; start in foreground if true; default false +silent=false ; no logs to stdout if true; default false +minfds=1024 ; min. avail startup file descriptors; default 1024 +minprocs=200 ; min. avail process descriptors;default 200 +;umask=022 ; process file creation umask; default 022 +user=root ; setuid to this UNIX account at startup; recommended if root +;identifier=supervisor ; supervisord identifier, default is 'supervisor' +;directory=/tmp ; default is not to cd during start +;nocleanup=true ; don't clean up tempfiles at start; default false +;childlogdir=/tmp ; 'AUTO' child log dir, default $TEMP +;environment=KEY="value" ; key value pairs to add to environment +;strip_ansi=false ; strip ansi escape codes in logs; def. false + +; The rpcinterface:supervisor section must remain in the config file for +; RPC (supervisorctl/web interface) to work. Additional interfaces may be +; added by defining them in separate [rpcinterface:x] sections. + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +; The supervisorctl section configures how supervisorctl will connect to +; supervisord. configure it match the settings in either the unix_http_server +; or inet_http_server section. + +[supervisorctl] +serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket +;serverurl=http://127.0.0.1:9001 ; use an http:// url to specify an inet socket +;username=chris ; should be same as in [*_http_server] if set +;password=123 ; should be same as in [*_http_server] if set +;prompt=mysupervisor ; cmd line prompt (default "supervisor") +;history_file=~/.sc_history ; use readline history if available + +; The sample program section below shows all possible program subsection values. +; Create one or more 'real' program: sections to be able to control them under +; supervisor. + +;[program:theprogramname] +;command=/bin/cat ; the program (relative uses PATH, can take args) +;process_name=%(program_name)s ; process_name expr (default %(program_name)s) +;numprocs=1 ; number of processes copies to start (def 1) +;directory=/tmp ; directory to cwd to before exec (def no cwd) +;umask=022 ; umask for process (default None) +;priority=999 ; the relative start priority (default 999) +;autostart=true ; start at supervisord start (default: true) +;startsecs=1 ; # of secs prog must stay up to be running (def. 1) +;startretries=3 ; max # of serial start failures when starting (default 3) +;autorestart=unexpected ; when to restart if exited after running (def: unexpected) +;exitcodes=0 ; 'expected' exit codes used with autorestart (default 0) +;stopsignal=QUIT ; signal used to kill process (default TERM) +;stopwaitsecs=10 ; max num secs to wait b4 SIGKILL (default 10) +;stopasgroup=false ; send stop signal to the UNIX process group (default false) +;killasgroup=false ; SIGKILL the UNIX process group (def false) +;user=root ; setuid to this UNIX account to run the program +;redirect_stderr=true ; redirect proc stderr to stdout (default false) +;stdout_logfile=/a/path ; stdout log path, NONE for none; default AUTO +;stdout_logfile_maxbytes=1MB ; max # logfile bytes b4 rotation (default 50MB) +;stdout_logfile_backups=10 ; # of stdout logfile backups (0 means none, default 10) +;stdout_capture_maxbytes=1MB ; number of bytes in 'capturemode' (default 0) +;stdout_events_enabled=false ; emit events on stdout writes (default false) +;stdout_syslog=false ; send stdout to syslog with process name (default false) +;stderr_logfile=/a/path ; stderr log path, NONE for none; default AUTO +;stderr_logfile_maxbytes=1MB ; max # logfile bytes b4 rotation (default 50MB) +;stderr_logfile_backups=10 ; # of stderr logfile backups (0 means none, default 10) +;stderr_capture_maxbytes=1MB ; number of bytes in 'capturemode' (default 0) +;stderr_events_enabled=false ; emit events on stderr writes (default false) +;stderr_syslog=false ; send stderr to syslog with process name (default false) +;environment=A="1",B="2" ; process environment additions (def no adds) +;serverurl=AUTO ; override serverurl computation (childutils) + +; The sample eventlistener section below shows all possible eventlistener +; subsection values. Create one or more 'real' eventlistener: sections to be +; able to handle event notifications sent by supervisord. + +;[eventlistener:theeventlistenername] +;command=/bin/eventlistener ; the program (relative uses PATH, can take args) +;process_name=%(program_name)s ; process_name expr (default %(program_name)s) +;numprocs=1 ; number of processes copies to start (def 1) +;events=EVENT ; event notif. types to subscribe to (req'd) +;buffer_size=10 ; event buffer queue size (default 10) +;directory=/tmp ; directory to cwd to before exec (def no cwd) +;umask=022 ; umask for process (default None) +;priority=-1 ; the relative start priority (default -1) +;autostart=true ; start at supervisord start (default: true) +;startsecs=1 ; # of secs prog must stay up to be running (def. 1) +;startretries=3 ; max # of serial start failures when starting (default 3) +;autorestart=unexpected ; autorestart if exited after running (def: unexpected) +;exitcodes=0 ; 'expected' exit codes used with autorestart (default 0) +;stopsignal=QUIT ; signal used to kill process (default TERM) +;stopwaitsecs=10 ; max num secs to wait b4 SIGKILL (default 10) +;stopasgroup=false ; send stop signal to the UNIX process group (default false) +;killasgroup=false ; SIGKILL the UNIX process group (def false) +;user=chrism ; setuid to this UNIX account to run the program +;redirect_stderr=false ; redirect_stderr=true is not allowed for eventlisteners + +;[group:thegroupname] +;programs=progname1,progname2 ; each refers to 'x' in [program:x] definitions +;priority=999 ; the relative start priority (default 999) + +; The [include] section can just contain the "files" setting. This +; setting can list multiple files (separated by whitespace or +; newlines). It can also contain wildcards. The filenames are +; interpreted as relative to this file. Included files *cannot* +; include files themselves. + +[include] +files = /etc/supervisor/conf.d/*.conf diff --git a/pre-commit.sh b/pre-commit.sh new file mode 100755 index 0000000..7fc7bf2 --- /dev/null +++ b/pre-commit.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +pre-commit run --all-files --verbose --show-diff-on-failure diff --git a/pyproject.toml b/pyproject.toml new file mode 100755 index 0000000..7271d02 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[project] +name = "app" +description = """ +A RBAC (Role-Based Access Control) permission control system built on FastAPI, featuring a unique pseudo-three-tier +architecture design, with built-in basic implementation of fastapi admin as a template library, free and open-source. +""" +authors = [ + { name = "Felix", email = "hengzone@outlook.com" }, +] +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +dynamic = ['version'] +dependencies = [ + "aiofiles>=24.1.0", + "aiosmtplib>=4.0.2", + "alembic>=1.16.5", + "asgi-correlation-id>=4.3.4", + "asgiref>=3.9.1", + "asyncmy>=0.2.10", + "asyncpg>=0.30.0", + "apscheduler==3.11.0", + "bcrypt>=4.3.0", + "cappa>=0.30.0", + "cryptography>=45.0.6", + "dulwich>=0.24.1", + "fast-captcha>=0.3.2", + "fastapi-limiter>=0.1.6", + "fastapi-pagination>=0.14.0", + "fastapi[standard-no-fastapi-cloud-cli]>=0.116.1", + "fastapi-utilities==0.3.1", + "flower>=2.0.1", + "gevent>=25.8.2", + "granian>=2.5.1", + "ip2loc>=1.0.0", + "itsdangerous>=2.2.0", + "jinja2>=3.1.6", + "loguru>=0.7.3", + "msgspec>=0.19.0", + "psutil>=7.0.0", + "psycopg[binary]>=3.2.9", + "pwdlib>=0.2.1", + "pydantic>=2.11.7", + "pydantic-settings>=2.10.1", + "pymysql>=1.1.1", + "python-jose>=3.5.0", + "python-socketio>=5.13.0", + "pycrypto==2.6.1", + "redis[hiredis]>=6.4.0", + "rtoml>=0.12.0", + "sqlalchemy-crud-plus>=1.11.0", + "sqlalchemy[asyncio]>=2.0.43", + "sqlparse>=0.5.3", + "user-agents>=2.2.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.4.0", + "pytest-sugar>=1.1.1", +] +lint = [ + "pre-commit>=4.3.0", +] +server = [ + "aio-pika>=9.5.7", + "wait-for-it>=2.3.0", +] + +[tool.uv] +python-downloads = "manual" +default-groups = ["dev", "lint"] + +[[tool.uv.index]] +name = "aliyun" +url = "https://mirrors.aliyun.com/pypi/simple" + +[tool.hatch.build.targets.wheel] +packages = ["backend"] + +[tool.hatch.version] +path = "backend/__init__.py" + +[project.scripts] +myapp = "backend.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..b39c147 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,321 @@ +# This file was autogenerated by uv via the following command: +# uv export -o requirements.txt --no-hashes +aiofiles==24.1.0 + # via fastapi-best-architecture +alembic==1.15.1 + # via fastapi-best-architecture +amqp==5.3.1 + # via kombu +annotated-types==0.7.0 + # via pydantic +anyio==4.9.0 + # via + # httpx + # starlette + # watchfiles +apscheduler==3.11.0 +asgi-correlation-id==4.3.4 + # via fastapi-best-architecture +asgiref==3.8.1 + # via fastapi-best-architecture +async-timeout==5.0.1 ; python_full_version < '3.11.3' + # via + # asyncpg + # redis +asyncmy==0.2.10 + # via fastapi-best-architecture +asyncpg==0.30.0 + # via fastapi-best-architecture +bcrypt==4.3.0 + # via fastapi-best-architecture +bidict==0.23.1 + # via python-socketio +billiard==4.2.1 + # via celery +certifi==2025.1.31 + # via + # httpcore + # httpx +cffi==1.17.1 ; platform_python_implementation != 'PyPy' + # via + # cryptography + # gevent +cfgv==3.4.0 + # via pre-commit +click==8.1.8 + # via + # celery + # click-didyoumean + # click-plugins + # click-repl + # typer + # uvicorn +click-didyoumean==0.3.1 + # via celery +click-plugins==1.1.1 + # via celery +click-repl==0.3.0 + # via celery +colorama==0.4.6 ; sys_platform == 'win32' + # via + # click + # loguru + # pytest + # uvicorn +cryptography==44.0.2 + # via fastapi-best-architecture +dashscope==1.23.9 +distlib==0.3.9 + # via virtualenv +dnspython==2.7.0 + # via email-validator +dulwich==0.22.8 + # via fastapi-best-architecture +ecdsa==0.19.1 + # via python-jose +email-validator==2.2.0 + # via fastapi +exceptiongroup==1.2.2 ; python_full_version < '3.11' + # via + # anyio + # pytest +fast-captcha==0.3.2 + # via fastapi-best-architecture +fastapi==0.115.11 + # via + # fastapi-best-architecture + # fastapi-limiter + # fastapi-pagination +fastapi-cli==0.0.5 + # via + # fastapi + # fastapi-best-architecture +fastapi-limiter==0.1.6 + # via fastapi-best-architecture +fastapi-pagination==0.13.0 + # via fastapi-best-architecture +fastapi-utilities==0.3.1 +filelock==3.18.0 + # via virtualenv +flower==2.0.1 + # via fastapi-best-architecture +gevent==24.11.1 + # via fastapi-best-architecture +greenlet==3.1.1 + # via + # gevent + # sqlalchemy +h11==0.14.0 + # via + # httpcore + # uvicorn + # wsproto +hiredis==3.1.0 + # via redis +httpcore==1.0.7 + # via httpx +httptools==0.6.4 + # via uvicorn +httpx==0.28.1 + # via fastapi +humanize==4.12.2 + # via flower +identify==2.6.9 + # via pre-commit +idna==3.10 + # via + # anyio + # email-validator + # httpx +iniconfig==2.1.0 + # via pytest +ip2loc==1.0.0 + # via fastapi-best-architecture +itsdangerous==2.2.0 + # via fastapi-best-architecture +jinja2==3.1.6 + # via + # fastapi + # fastapi-best-architecture +kombu==5.5.1 + # via celery +loguru==0.7.3 + # via fastapi-best-architecture +mako==1.3.9 + # via alembic +markdown-it-py==3.0.0 + # via rich +markupsafe==3.0.2 + # via + # jinja2 + # mako +mdurl==0.1.2 + # via markdown-it-py +msgspec==0.19.0 + # via fastapi-best-architecture +nodeenv==1.9.1 + # via pre-commit +packaging==24.2 + # via + # asgi-correlation-id + # pytest + # pytest-sugar +path==17.0.0 + # via fastapi-best-architecture +pillow==11.1.0 + # via fast-captcha +platformdirs==4.3.7 + # via virtualenv +pluggy==1.5.0 + # via pytest +pre-commit==4.2.0 +prometheus-client==0.21.1 + # via flower +prompt-toolkit==3.0.50 + # via click-repl +psutil==7.0.0 + # via fastapi-best-architecture +pwdlib==0.2.1 + # via fastapi-best-architecture +pyasn1==0.4.8 + # via + # python-jose + # rsa +pycparser==2.22 ; platform_python_implementation != 'PyPy' + # via cffi +pydantic==2.11.0 + # via + # fastapi + # fastapi-best-architecture + # fastapi-pagination + # pydantic-settings + # sqlalchemy-crud-plus +pydantic-core==2.33.0 + # via pydantic +pydantic-settings==2.8.1 + # via fastapi-best-architecture +pygments==2.19.1 + # via rich +pytest==8.3.5 + # via pytest-sugar +pytest-sugar==1.0.0 +python-dateutil==2.9.0.post0 + # via celery +python-dotenv==1.1.0 + # via + # pydantic-settings + # uvicorn +python-engineio==4.11.2 + # via python-socketio +python-jose==3.4.0 + # via fastapi-best-architecture +python-multipart==0.0.20 + # via fastapi +python-socketio==5.12.1 + # via fastapi-best-architecture +pycrypto==2.6.1 +pytz==2025.2 + # via flower +pyyaml==6.0.2 + # via + # pre-commit + # uvicorn +redis==5.2.1 + # via + # fastapi-best-architecture + # fastapi-limiter +rich==13.9.4 + # via typer +rsa==4.9 + # via python-jose +rtoml==0.12.0 + # via fastapi-best-architecture +setuptools==78.1.0 + # via + # zope-event + # zope-interface +shellingham==1.5.4 + # via typer +simple-websocket==1.1.0 + # via python-engineio +six==1.17.0 + # via + # ecdsa + # python-dateutil +sniffio==1.3.1 + # via anyio +sqlalchemy==2.0.40 + # via + # alembic + # fastapi-best-architecture + # sqlalchemy-crud-plus +sqlalchemy-crud-plus==1.10.0 + # via fastapi-best-architecture +starlette==0.46.1 + # via + # asgi-correlation-id + # fastapi +termcolor==2.5.0 + # via pytest-sugar +tomli==2.2.1 ; python_full_version < '3.11' + # via pytest +tornado==6.4.2 + # via flower +typer==0.15.2 + # via fastapi-cli +typing-extensions==4.13.0 + # via + # alembic + # anyio + # asgiref + # fastapi + # fastapi-pagination + # pydantic + # pydantic-core + # rich + # sqlalchemy + # typer + # typing-inspection + # uvicorn +typing-inspection==0.4.0 + # via pydantic +tzdata==2025.1 + # via + # celery + # kombu +ua-parser==1.0.1 + # via user-agents +ua-parser-builtins==0.18.0.post1 + # via ua-parser +urllib3==2.4.0 + # via dulwich +user-agents==2.2.0 + # via fastapi-best-architecture +uvicorn==0.34.0 + # via + # fastapi + # fastapi-cli +uvloop==0.21.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' + # via uvicorn +vine==5.1.0 + # via + # amqp + # celery + # kombu +virtualenv==20.29.3 + # via pre-commit +watchfiles==1.0.4 + # via uvicorn +wcwidth==0.2.13 + # via prompt-toolkit +websockets==15.0.1 +wechatpy==1.8.18 + # via uvicorn +win32-setctime==1.2.0 ; sys_platform == 'win32' + # via loguru +wsproto==1.2.0 + # via simple-websocket +zope-event==5.0 + # via gevent +zope-interface==7.2 + # via gevent diff --git a/uv.lock b/uv.lock new file mode 100755 index 0000000..6cfe9bd --- /dev/null +++ b/uv.lock @@ -0,0 +1,1956 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version < '3.11'", +] + +[[package]] +name = "alembic" +version = "1.13.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/24/ddce068e2ac9b5581bd58602edb2a1be1b0752e1ff2963c696ecdbe0470d/alembic-1.13.1.tar.gz", hash = "sha256:4932c8558bf68f2ee92b9bbcb8218671c627064d5b08939437af6d77dc05e595" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7f/50/9fb3a5c80df6eb6516693270621676980acd6d5a9a7efdbfa273f8d616c7/alembic-1.13.1-py3-none-any.whl", hash = "sha256:2edcc97bed0bd3272611ce3a98d98279e9c209e7186e43e75bbb1b2bdfdbcc43" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c" }, +] + +[[package]] +name = "asyncmy" +version = "0.2.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/50/1e/67ec08cde59222d275909a508ad2db1ac8c20a72404a189dca31e242179b/asyncmy-0.2.9.tar.gz", hash = "sha256:da188be013291d1f831d63cdd3614567f4c63bfdcde73631ddff8df00c56d614" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/06/96/297b2126fb9a4b36b846e2f38d45551f3ea1b6105748c5176050c6eca193/asyncmy-0.2.9-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d077eaee9a126f36bbe95e0412baa89e93172dd46193ef7bf7650a686e458e50" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/14/07fe6fa52f051f589ebee73abc09f56900ab53646af15a8d572f5f08373e/asyncmy-0.2.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83cf951a44294626df43c5a85cf328297c3bac63f25ede216f9706514dabb322" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/1b/69c2e9f87c08cc954ecbf617363cd16fc92ba9a206e9a47cb42469154dae/asyncmy-0.2.9-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:8a1d63c1bb8e3a09c90767199954fd423c48084a1f6c0d956217bc2e48d37d6d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/97/a45be22b99cf1fc0c6411f22cc7b100ff970badc3a50a00544b9f3699963/asyncmy-0.2.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ecad6826086e47596c6aa65dcbe221305f3d9232f0d4de11b8562ee2c55464a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/53/03b935188ecdfbc07c23f6faf182251b90963c8d62a46401bc3ab6f1ea75/asyncmy-0.2.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4a664d58f9ebe4132f6cb3128206392be8ad71ad6fb09a5f4a990b04ec142024" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/00/0662cbb80f341546f7e473fbaba5f94e5ac5b055c65b64aa3e8cc5660863/asyncmy-0.2.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f2bbd7b75e2d751216f48c3b1b5092b812d70c2cd0053f8d2f50ec3f76a525a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/63/705c06ef4928b89960d1901810571a77dad18d836ec546d3634e11121974/asyncmy-0.2.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:55e3bc41aa0d4ab410fc3a1d0c31b9cdb6688cd3b0cae6f2ee49c2e7f42968be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/d7/511f527eeb021974e89a0c32f8208cc61482a20c9fbac9bd157b04c0412f/asyncmy-0.2.9-cp310-cp310-win32.whl", hash = "sha256:ea44eefc965c62bcfebf34e9ef00f6e807edf51046046767c56914243e0737e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/74/407dbd8feff592229184954a16f8c7fe3251accec4a40f6654f05068759a/asyncmy-0.2.9-cp310-cp310-win_amd64.whl", hash = "sha256:2b4a2a7cf0bd5051931756e765fefef3c9f9561550e0dd8b1e79308d048b710a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/f7/8cfb0c43ca8e2abb8e399b22752313d2e645f75bd8392ec4d199f8c39f58/asyncmy-0.2.9-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:e2b77f03a17a8db338d74311e38ca6dbd4ff9aacb07d2af6b9e0cac9cf1c7b87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/38/6939eda9d32e5566d554709bd2b2008bf4139c050ef62f7b49ee96b28732/asyncmy-0.2.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c19f27b7ff0e297f2981335a85599ffe1c9a8a35c97230203321d5d6e9e4cb30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/45/f8c3873be7249fbfb87a33be0ee98269356a679b2aaf3d1ce732199bcaf5/asyncmy-0.2.9-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:bf18aef65ac98f5130ca588c55a83a56e74ae416cf0fe2c0757a2b597c4269d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e1/54/cb1ad6dc4322126a29f6998f8d2f842255106879230c5a0ee3b5bb2743eb/asyncmy-0.2.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef02186cc02cb767ee5d5cf9ab002d5c7910a1a9f4c16a666867a9325c9ec5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/b2/493084b8c3bd8a4a3d937bd7c888e424be40e2c80fd5331a6638b5c7006a/asyncmy-0.2.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:696da0f71db0fe11e62fa58cd5a27d7c9d9a90699d13d82640755d0061da0624" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/31/9b329897c4ee1bd9ef1a55ee35c23a8445f058c1a18198ad047a4aac906e/asyncmy-0.2.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:84d20745bb187ced05bd4072ae8b0bff4b4622efa23b79935519edb717174584" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/68/09ec0526fff11419a9a39e6ca5a89f567aa6bad79f81dc1bdcbf4a9e712a/asyncmy-0.2.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ea242364523f6205c4426435272bd57cbf593c20d5e5551efb28d44cfbd595c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/94/cc0d167d40ea8f6b219b15a2239ac0b7a456f5a772834801c6f85c8eebe1/asyncmy-0.2.9-cp311-cp311-win32.whl", hash = "sha256:47609d34e6b49fc5ad5bd2a2a593ca120e143e2a4f4206f27a543c5c598a18ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/41/af76dace00385e7cdcdd814f2288725ed7842a3d06f37f0d6f773716193b/asyncmy-0.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:0d56df7342f7b5467a9d09a854f0e5602c8da09afdad8181ba40b0434d66d8a4" }, +] + +[[package]] +name = "bcrypt" +version = "4.1.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ca/e9/0b36987abbcd8c9210c7b86673d88ff0a481b4610630710fb80ba5661356/bcrypt-4.1.3.tar.gz", hash = "sha256:2ee15dd749f5952fe3f0430d0ff6b74082e159c50332a1413d51b5689cf06623" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fe/4e/e424a74f0749998d8465c162c5cb9d9f210a5b60444f4120eff0af3fa800/bcrypt-4.1.3-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:48429c83292b57bf4af6ab75809f8f4daf52aa5d480632e53707805cc1ce9b74" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/8d/ad2efe0ec57ed3c25e588c4543d946a1c72f8ee357a121c0e382d8aaa93f/bcrypt-4.1.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a8bea4c152b91fd8319fef4c6a790da5c07840421c2b785084989bf8bbb7455" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/f6/9c0a6de7ef78d573e10d0b7de3ef82454e2e6eb6fada453ea6c2b8fb3f0a/bcrypt-4.1.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d3b317050a9a711a5c7214bf04e28333cf528e0ed0ec9a4e55ba628d0f07c1a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/56/45312e49c195cd30e1bf4b7f0e039e8b3c46802cd55485947ddcb96caa27/bcrypt-4.1.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:094fd31e08c2b102a14880ee5b3d09913ecf334cd604af27e1013c76831f7b05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/6a/ce950d4350c734bc5d9b7196a58fedbdc94f564c00b495a1222984431e03/bcrypt-4.1.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4fb253d65da30d9269e0a6f4b0de32bd657a0208a6f4e43d3e645774fb5457f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/a1/36aa84027ef45558b30a485bc5b0606d5e7357b27b10cc49dce3944f4d1d/bcrypt-4.1.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:193bb49eeeb9c1e2db9ba65d09dc6384edd5608d9d672b4125e9320af9153a15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/e8/183ead5dd8124e463d0946dfaf86c658225adde036aede8384d21d1794d0/bcrypt-4.1.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:8cbb119267068c2581ae38790e0d1fbae65d0725247a930fc9900c285d95725d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/5e/edcb4ec57b056ca9d5f9fde31fcda10cc635def48867edff5cc09a348a4f/bcrypt-4.1.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6cac78a8d42f9d120b3987f82252bdbeb7e6e900a5e1ba37f6be6fe4e3848286" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/5d/121130cc85009070fe4e4f5937b213a00db143147bc6c8677b3fd03deec8/bcrypt-4.1.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:01746eb2c4299dd0ae1670234bf77704f581dd72cc180f444bfe74eb80495b64" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/ac/bcb7d3ac8a1107b103f4a95c5be088b984d8045d4150294459a657870bd9/bcrypt-4.1.3-cp37-abi3-win32.whl", hash = "sha256:037c5bf7c196a63dcce75545c8874610c600809d5d82c305dd327cd4969995bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/57/3856b1728018f5ce85bb678a76e939cb154a2e1f9c5aa69b83ec5652b111/bcrypt-4.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:8a893d192dfb7c8e883c4576813bf18bb9d59e2cfd88b68b725990f033f1b978" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/eb/fbea8d2b370a4cc7f5f0aff9f492177a5813e130edeab9dd388ddd3ef1dc/bcrypt-4.1.3-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d4cf6ef1525f79255ef048b3489602868c47aea61f375377f0d00514fe4a78c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/9a/4aa31d1de9369737cfa734a60c3d125ecbd1b3ae2c6499986d0ac160ea8b/bcrypt-4.1.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5698ce5292a4e4b9e5861f7e53b1d89242ad39d54c3da451a93cac17b61921a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/d4/13b86b1bb2969a804c2347d0ad72fc3d3d9f5cf0d876c84451c6480e19bc/bcrypt-4.1.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec3c2e1ca3e5c4b9edb94290b356d082b721f3f50758bce7cce11d8a7c89ce84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/3c/6e478265f68eff764571676c0773086d15378fdf5347ddf53e5025c8b956/bcrypt-4.1.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3a5be252fef513363fe281bafc596c31b552cf81d04c5085bc5dac29670faa08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/00/21e34b365b895e6faf9cc5d4e7b97dd419e08f8a7df119792ec206b4a3fa/bcrypt-4.1.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5f7cd3399fbc4ec290378b541b0cf3d4398e4737a65d0f938c7c0f9d5e686611" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/c9/069b0c3683ce969b328b7b3e3218f9d5981d0629f6091b3b1dfa72928f75/bcrypt-4.1.3-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:c4c8d9b3e97209dd7111bf726e79f638ad9224b4691d1c7cfefa571a09b1b2d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/fd/0d2d7cc6fc816010f6c6273b778e2f147e2eca1144975b6b71e344b26ca0/bcrypt-4.1.3-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:31adb9cbb8737a581a843e13df22ffb7c84638342de3708a98d5c986770f2834" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/85/283450ee672719e216a5e1b0e80cb0c8f225bc0814cbb893155ee4fdbb9e/bcrypt-4.1.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:551b320396e1d05e49cc18dd77d970accd52b322441628aca04801bbd1d52a73" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/64/a016d23b6f513282d8b7f9dd91342929a2e970b2e2c2576d9b76f8f2ee5a/bcrypt-4.1.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6717543d2c110a155e6821ce5670c1f512f602eabb77dba95717ca76af79867d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/35/036d69e46c60394f2ffb474c9a4b3783e84395bf4ad55176771f603069ca/bcrypt-4.1.3-cp39-abi3-win32.whl", hash = "sha256:6004f5229b50f8493c49232b8e75726b568535fd300e5039e255d919fc3a07f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/46/fada28872f3f3e121868f4cd2d61dcdc7085a07821debf1320cafeabc0db/bcrypt-4.1.3-cp39-abi3-win_amd64.whl", hash = "sha256:2505b54afb074627111b5a8dc9b6ae69d0f01fea65c2fcaea403448c503d3991" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/02/c23ca3cf171798af16361576ef43bfc3192e3c3a679bc557e046b02c7188/bcrypt-4.1.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:cb9c707c10bddaf9e5ba7cdb769f3e889e60b7d4fea22834b261f51ca2b89fed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/79/5dd98e9f67701be98d52f7ee181aa3b078d45eab62ed5f9aa987676d049c/bcrypt-4.1.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9f8ea645eb94fb6e7bea0cf4ba121c07a3a182ac52876493870033141aa687bc" }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "cryptography" +version = "42.0.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/78/63/66c03eb51f0d241862083deb3f17ab5fce08cf6b347db7887bcb4d1a194e/cryptography-42.0.7.tar.gz", hash = "sha256:ecbfbc00bf55888edda9868a4cf927205de8499e7fabe6c050322298382953f2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/aa/b5/57982a4ca3542daeabee2303263a8b9d59968d47a1977a36f6aa9344e32e/cryptography-42.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a987f840718078212fdf4504d0fd4c6effe34a7e4740378e59d47696e8dfb477" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/f7/25186f6ef7df5dc1883ceee11f20305749a7f49ee4066a21818cbe67514e/cryptography-42.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bd13b5e9b543532453de08bcdc3cc7cebec6f9883e886fd20a92f26940fd3e7a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/df/f528d1deadd8699f876775c59784ce111bb8b2b0c258c53b16fcf91cad33/cryptography-42.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a79165431551042cc9d1d90e6145d5d0d3ab0f2d66326c201d9b0e7f5bf43604" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/26/4668c980f63a04c697bac26559c1a2a3a2d0730dc3d0f88b87ed7abf76ca/cryptography-42.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47787a5e3649008a1102d3df55424e86606c9bae6fb77ac59afe06d234605f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/0c/36f71286fef9987f0e850f9b9a78a41784fe7f66c3b7611530b6bb0ecec4/cryptography-42.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:02c0eee2d7133bdbbc5e24441258d5d2244beb31da5ed19fbb80315f4bbbff55" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/17/0294e576979d7f388855bf64b3cc6d9fc08168cdf6833ff0df3ad0ee6580/cryptography-42.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e44507bf8d14b36b8389b226665d597bc0f18ea035d75b4e53c7b1ea84583cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/08/57c7815d3bb5edeb95da7845cfbd9604393eee955fe139710014f107dac5/cryptography-42.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f8b25fa616d8b846aef64b15c606bb0828dbc35faf90566eb139aa9cff67af2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/75/a04c67c659888d03f164f58851a3522c16d3c09ce3de5734e6e34b48ec6d/cryptography-42.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:93a3209f6bb2b33e725ed08ee0991b92976dfdcf4e8b38646540674fc7508e13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/12/6cdd1914e3b443f10f27c1b249cbdab0134962c3cfce25f1b4ae2d087ebf/cryptography-42.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6b8f1881dac458c34778d0a424ae5769de30544fc678eac51c1c8bb2183e9da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/eb/b03078f34bfe9c11339773a49c75cb2232471bc7b56b3c353b6a27aed8e8/cryptography-42.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3de9a45d3b2b7d8088c3fbf1ed4395dfeff79d07842217b38df14ef09ce1d8d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/f9/1e703c76db2fcdfb1becba2298edaec2ed48eaac12afacba17ceac3842af/cryptography-42.0.7-cp37-abi3-win32.whl", hash = "sha256:789caea816c6704f63f6241a519bfa347f72fbd67ba28d04636b7c6b7da94b0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/03/2d6f5513625ca68cc36776794a1925b75d1d22b7486e87738ef1c9667d94/cryptography-42.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:8cb8ce7c3347fcf9446f201dc30e2d5a3c898d009126010cbd1f443f28b52678" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/c2/8226676b3a4916a12d6c243b1934894e333ea2e97d0233f3260955ed2673/cryptography-42.0.7-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:a3a5ac8b56fe37f3125e5b72b61dcde43283e5370827f5233893d461b7360cd4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/91/762c76c55db47cb94e1ba91ec6734a4f1e64466cbef5ef8cd63c5ab4f31c/cryptography-42.0.7-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:779245e13b9a6638df14641d029add5dc17edbef6ec915688f3acb9e720a5858" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/fd/4525835d3e42e2db30169d42215ce74762f447fcbf01ed02f74f59bd74cb/cryptography-42.0.7-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d563795db98b4cd57742a78a288cdbdc9daedac29f2239793071fe114f13785" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/fc/bc9d580e480741c05fc6df6f9774dfe33ae42905361661d5d2616476bb62/cryptography-42.0.7-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:31adb7d06fe4383226c3e963471f6837742889b3c4caa55aac20ad951bc8ffda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/4e/fa4896744259ee8602464ed2c7330b736cc4dd3fd92f63cd56828bf36707/cryptography-42.0.7-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:efd0bf5205240182e0f13bcaea41be4fdf5c22c5129fc7ced4a0282ac86998c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/b8/08bdd08847b42a9d6d29604bdf6db0521b9c71f5334f0838e082a68b3684/cryptography-42.0.7-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a9bc127cdc4ecf87a5ea22a2556cab6c7eda2923f84e4f3cc588e8470ce4e42e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/0d/5476959d71a4c427cf02f278a8984cb4197e6a41417961defd8f0aba88f5/cryptography-42.0.7-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3577d029bc3f4827dd5bf8bf7710cac13527b470bbf1820a3f394adb38ed7d5f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/df/f2f6cc5124fb85ff84615d62b996997b22a1c7c37a5ff3f5e8907a5ffa86/cryptography-42.0.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e47577f9b18723fa294b0ea9a17d5e53a227867a0a4904a1a076d1646d45ca1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/8b/2a41805540e49df1ea053f0dfef0340964fe422d61f6e398d16bf6c4013b/cryptography-42.0.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1a58839984d9cb34c855197043eaae2c187d930ca6d644612843b4fe8513c886" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/d9/7a70f15b51ab5ab71f7b5f405f6abef67a7aa49efdcc0c0ade215db7d7b8/cryptography-42.0.7-cp39-abi3-win32.whl", hash = "sha256:e6b79d0adb01aae87e8a44c2b64bc3f3fe59515280e00fb6d57a7267a2583cda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/59/17c9070c5c397881900c868a5c4e4a522437e3e1653294925082e5f6cf0b/cryptography-42.0.7-cp39-abi3-win_amd64.whl", hash = "sha256:16268d46086bb8ad5bf0a2b5544d8a9ed87a0e33f5e77dd3c3301e63d941a83b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/56/45e7db74b8bd14e32238a7709c94fd40dbe9a38dccec6afb43afcc6cad84/cryptography-42.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2954fccea107026512b15afb4aa664a5640cd0af630e2ee3962f2602693f0c82" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/0f/35ffbbc42db7e61af62298ed057ac43fb958025f5191ce9720e6b8bc4127/cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:362e7197754c231797ec45ee081f3088a27a47c6c01eff2ac83f60f85a50fe60" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/21/aa22aedd528839c09cf867b02124ff41109859c80c1bf18b9de342157f95/cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f698edacf9c9e0371112792558d2f705b5645076cc0aaae02f816a0171770fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/a4/28dd1dddbd68c9bb33a8527548cc675c23d1569e8ad5ca560aecf73b1640/cryptography-42.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5482e789294854c28237bba77c4c83be698be740e31a3ae5e879ee5444166582" }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87" }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3" }, +] + +[[package]] +name = "email-validator" +version = "2.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/63/82/2914bff80ebee8c027802a664ad4b4caad502cd594e358f76aff395b5e56/email_validator-2.1.1.tar.gz", hash = "sha256:200a70680ba08904be6d1eef729205cc0d687634399a5924d842533efb824b84" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e4/60/b02cb0f5ee0be88bd4fbfdd9cc91e43ec2dfcc47fe064e7c70587ff58a94/email_validator-2.1.1-py3-none-any.whl", hash = "sha256:97d882d174e2a65732fb43bfce81a3a834cbc1bde8bf419e30ef5ea976370a05" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b" }, +] + +[[package]] +name = "fast-captcha" +version = "0.2.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cb/13/30e650fbf1fed4f029cf5b87e7b032f70648f7de3663350660f0dc4ca9b3/fast_captcha-0.2.1.tar.gz", hash = "sha256:63130402bdf8945974702ff136c05f98191b215083182c5684f694c3c1f7d257" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/87/e8/dac53f51c0ddb45897e22d5e76f8917429c15efd928184e218845cb78ad8/fast_captcha-0.2.1-py3-none-any.whl", hash = "sha256:fef14828609a96cc286f9b09c84126628221ae578d3b5b984d50e03834c07f7d" }, +] + +[[package]] +name = "fastapi" +version = "0.111.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "email-validator" }, + { name = "fastapi-cli" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "orjson" }, + { name = "pydantic" }, + { name = "python-multipart" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "ujson" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0e/1f/f4a99e92c583780787e04b05aa9d8a8db9ec76d091d81545948a006f5b44/fastapi-0.111.0.tar.gz", hash = "sha256:b9db9dd147c91cb8b769f7183535773d8741dd46f9dc6676cd82eab510228cd7" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e6/33/de41e554e5a187d583906e10d53bfae5fd6c07e98cbf4fe5262bd37e739a/fastapi-0.111.0-py3-none-any.whl", hash = "sha256:97ecbf994be0bcbdadedf88c3150252bed7b2087075ac99735403b1b76cc8fc0" }, +] + +[package.optional-dependencies] +all = [ + { name = "email-validator" }, + { name = "httpx" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "orjson" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "ujson" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4" }, +] + +[[package]] +name = "fastapi-limiter" +version = "0.1.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "redis" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7f/99/c7903234488d4dca5f9bccb4f88c2f582a234f0dca33348781c9cf8a48c6/fastapi_limiter-0.1.6.tar.gz", hash = "sha256:6f5fde8efebe12eb33861bdffb91009f699369a3c2862cdc7c1d9acf912ff443" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cd/b5/6f6b4d18bee1cafc857eae12738b3a03b7d1102b833668be868938c57b9d/fastapi_limiter-0.1.6-py3-none-any.whl", hash = "sha256:2e53179a4208b8f2c8795e38bb001324d3dc37d2800ff49fd28ec5caabf7a240" }, +] + +[[package]] +name = "fastapi-pagination" +version = "0.12.24" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/89/75/3c7e563beda6e0e1cbb8087e8b07d2c0cb389e7b324753321b338708df99/fastapi_pagination-0.12.24.tar.gz", hash = "sha256:c9c6508e0182aab679a13b1de261d4923e3b530b410500dcb271638ff714fb14" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b6/43/5a2c832e02fd2a4ba6037b52d5bd8518ec32e7cba14dd55bbec396685bb0/fastapi_pagination-0.12.24-py3-none-any.whl", hash = "sha256:a639df7301a89414244c6763bb97cff043815cb839070b8a38c58c007cf75d48" }, +] + +[[package]] +name = "fastapi-sqlalchemy-mysql" +version = "0.0.1" +source = { virtual = "." } +dependencies = [ + { name = "alembic" }, + { name = "asyncmy" }, + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "email-validator" }, + { name = "fast-captcha" }, + { name = "fastapi", extra = ["all"] }, + { name = "fastapi-limiter" }, + { name = "fastapi-pagination" }, + { name = "loguru" }, + { name = "msgspec" }, + { name = "path" }, + { name = "pgvector" }, + { name = "psycopg2-binary" }, + { name = "pwdlib" }, + { name = "pyliquibase" }, + { name = "python-dotenv" }, + { name = "python-jose" }, + { name = "python-multipart" }, + { name = "redis", extra = ["hiredis"] }, + { name = "sqlalchemy" }, + { name = "sqlalchemy-crud-plus" }, + { name = "tzdata" }, +] + +[package.dev-dependencies] +lint = [ + { name = "pre-commit" }, +] +server = [ + { name = "gunicorn" }, + { name = "wait-for-it" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = "==1.13.1" }, + { name = "asyncmy", specifier = "==0.2.9" }, + { name = "bcrypt", specifier = "==4.1.3" }, + { name = "cryptography", specifier = "==42.0.7" }, + { name = "email-validator", specifier = "==2.1.1" }, + { name = "fast-captcha", specifier = "==0.2.1" }, + { name = "fastapi", extras = ["all"], specifier = "==0.111.0" }, + { name = "fastapi-limiter", specifier = "==0.1.6" }, + { name = "fastapi-pagination", specifier = "==0.12.24" }, + { name = "loguru", specifier = "==0.7.2" }, + { name = "msgspec", specifier = ">=0.18.6" }, + { name = "path", specifier = "==16.14.0" }, + { name = "pgvector", specifier = ">=0.4.1" }, + { name = "psycopg2-binary", specifier = ">=2.9.10" }, + { name = "pwdlib", specifier = ">=0.2.1" }, + { name = "pyliquibase", specifier = ">=2.4.0" }, + { name = "python-dotenv", specifier = ">=1.1.0" }, + { name = "python-jose", specifier = "==3.3.0" }, + { name = "python-multipart", specifier = "==0.0.9" }, + { name = "redis", extras = ["hiredis"], specifier = "==5.0.4" }, + { name = "sqlalchemy", specifier = "==2.0.30" }, + { name = "sqlalchemy-crud-plus", specifier = ">=1.6.0" }, + { name = "tzdata", specifier = "==2024.1" }, +] + +[package.metadata.requires-dev] +lint = [{ name = "pre-commit", specifier = ">=4.0.0" }] +server = [ + { name = "gunicorn", specifier = ">=21.2.0" }, + { name = "wait-for-it", specifier = ">=2.2.2" }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de" }, +] + +[[package]] +name = "greenlet" +version = "3.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/25/90/5234a78dc0ef6496a6eb97b67a42a8e96742a56f7dc808cb954a85390448/greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/16/cd631fa0ab7d06ef06387135b7549fdcc77d8d859ed770a0d28e47b20972/greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/b1/aed39043a6fec33c284a2c9abd63ce191f4f1a07319340ffc04d2ed3256f/greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/25/40e0112f7f3ebe54e8e8ed91b2b9f970805143efef16d043dfc15e70f44b/greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/2f/3850b867a9af519794784a7eeed1dd5bc68ffbcc5b28cef703711025fd0a/greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/69/79e4d63b9387b48939096e25115b8af7cd8a90397a304f92436bcb21f5b2/greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/1d/44dbcb0e6c323bd6f71b8c2f4233766a5faf4b8948873225d34a0b7efa71/greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/1d/a305dce121838d0278cee39d5bb268c657f10a5363ae4b726848f833f1bb/greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/28/d62835fb33fb5652f2e98d34c44ad1a0feacc8b1d3f1aecab035f51f267d/greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/62/1c2665558618553c42922ed47a4e6d6527e2fa3516a8256c2f431c5d0441/greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/9d/421e2d5f07285b6e4e3a676b016ca781f63cfe4a0cd8eaecf3fd6f7a71ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/de/6e05f5c59262a584e502dd3d261bbdd2c97ab5416cc9c0b91ea38932a901/greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/93/d5f93c84241acdea15a8fd329362c2c71c79e1a507c3f142a5d67ea435ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/85/72f77fc02d00470c86a5c982b8daafdf65d38aefbbe441cebff3bf7037fc/greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/4b/1c9695aa24f808e156c8f4813f685d975ca73c000c2a5056c514c64980f6/greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/70/ad6e5b31ef330f03b12559d19fda2606a522d3849cde46b24f223d6d1619/greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/fb/201e1b932e584066e0f0658b538e73c459b34d44b4bd4034f682423bc801/greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/da/b9ed5e310bb8b89661b80cbcd4db5a067903bbcd7fc854923f5ebb4144f0/greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6" }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d" }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" }, +] + +[[package]] +name = "hiredis" +version = "3.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/38/e5/789cfa8993ced0061a6ef7ea758302ef5cf3439629bf0d39c85a6ede4641/hiredis-3.1.0.tar.gz", hash = "sha256:51d40ac3611091020d7dea6b05ed62cb152bff595fa4f931e7b6479d777acf7c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9e/13/636d4eedc20ac6962439f72b4dc7c2906e68c4f1df88cea5ebf916125cd5/hiredis-3.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:2892db9db21f0cf7cc298d09f85d3e1f6dc4c4c24463ab67f79bc7a006d51867" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/99/af3f3720c769292d159b12684f867641a47331d918bc3b820162c50c1861/hiredis-3.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:93cfa6cc25ee2ceb0be81dc61eca9995160b9e16bdb7cca4a00607d57e998918" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/1a/bc12c0e7688f23c33baad05bbd6fd2b944a35c2d3adad59a794ce7e62d70/hiredis-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2af62070aa9433802cae7be7364d5e82f76462c6a2ae34e53008b637aaa9a156" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/8c/95c95a2bd6fb04b549083530c08bb1004c4a18a870c2eb8ac0e7f5b37879/hiredis-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:072c162260ebb1d892683107da22d0d5da7a1414739eae4e185cac22fe89627f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/34/88c4fafe7c6df13c81c02bc51bda9190830b2e7d0d51e685f054573a2caa/hiredis-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6b232c43e89755ba332c2745ddab059c0bc1a0f01448a3a14d506f8448b1ce6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/ae/f6fefb942ab27e33416b9734dc06995d4fd6f4daaf6855da520c851417c3/hiredis-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb5316c9a65c4dde80796aa245b76011bab64eb84461a77b0a61c1bf2970bcc9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/85/c77ff1a95318e12810f529abe08744c06137de84f17114297773b76825b8/hiredis-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e812a4e656bbd1c1c15c844b28259c49e26bb384837e44e8d2aa55412c91d2f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/a2/e98faec792f49361f2913d5b537270d180db67ec19be86324aa1610b1371/hiredis-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93a6c9230e5a5565847130c0e1005c8d3aa5ca681feb0ed542c4651323d32feb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/30/242b5795025ecd89c8c14de0bd8da9057e9e31348e5d3e739b63aac37f3e/hiredis-3.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a5f65e89ce50a94d9490d5442a649c6116f53f216c8c14eb37cf9637956482b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/46/e5539f1db6e88fa4ebcffc6691713ae45d6d764c7ef8600d943da75d6b6a/hiredis-3.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b2d6e33601c67c074c367fdccdd6033e642284e7a56adc130f18f724c378ca8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/47/f60b4d488bfba93eeaade3c859733f073cde40305c96437ff466b303143a/hiredis-3.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bad3b1e0c83849910f28c95953417106f539277035a4b515d1425f93947bc28f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/83/2ba481bb58b99a8e921289b1d57e69a4516b954bfd8aab00dd28749a2ed7/hiredis-3.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9646de31f5994e6218311dcf216e971703dbf804c510fd3f84ddb9813c495824" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/9d/e4f886d1db94f7cf0ccfc16e40da9a785fdd37cb6ba4d0b984477ff4d198/hiredis-3.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59a9230f3aa38a33d09d8171400de202f575d7a38869e5ce2947829bca6fe359" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/f6/fee28cf6eb54ce5c3bd21e1f157c99f451e76e145ac7a9aa04f7df83097d/hiredis-3.1.0-cp310-cp310-win32.whl", hash = "sha256:0322d70f3328b97da14b6e98b18f0090a12ed8a8bf7ae20932e2eb9d1bb0aa2c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/8a/a86859e5bdef1cab3299bdaeb3facaead074f246383312305a62aa877e97/hiredis-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:802474c18e878b3f9905e160a8b7df87d57885758083eda76c5978265acb41aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/85/9f738bab9f446e40a3a29aff0aa7766568b2680407e862667eaa3ec78bfe/hiredis-3.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:c339ff4b4739b2a40da463763dd566129762f72926bca611ad9a457a9fe64abd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/9c/c64ddce9768c3a95797db10f85ed80f80389693b558801a0256e7c8ea3db/hiredis-3.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:0ffa2552f704a45954627697a378fc2f559004e53055b82f00daf30bd4305330" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/68/b0d0909f86b01bb7be738be306dc536431f2af90a42155a2fafa05d528b9/hiredis-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9acf7f0e7106f631cd618eb60ec9bbd6e43045addd5310f66ba1177209567e59" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/3a/625227d3c26ee69ef0f5881c2e329f19d1d5fe6a880a0b5f7eaf2a1ae6ab/hiredis-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea4f5ecf9dbea93c827486f59c606684c3496ea71c7ba9a8131932780696e61a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/e1/c14f3c66c42f5746cd54156584dcf60540a9063f351e101e99fd074e80ae/hiredis-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39efab176fca3d5111075f6ba56cd864f18db46d858289d39360c5672e0e5c3e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/f4/a1d6972feb3be634ae7cdf719a56d5c7a8334f4202a05935b9c1b53d5ef6/hiredis-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1110eae007f30e70a058d743e369c24430327cd01fd97d99519d6794a58dd587" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/6f/630581e3c62a4651cb914da1619ddeb7b07f182e74748277244df914c107/hiredis-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b390f63191bcccbb6044d4c118acdf4fa55f38e5658ac4cfd5a33a6f0c07659" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/bcf5562fa50cdce19169d48bb3bc25690c27fde321f147b68781140c9d5d/hiredis-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72a98ccc7b8ec9ce0100ecf59f45f05d2023606e8e3676b07a316d1c1c364072" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/bd/902a6ad2832f6a517bc13b2fe30ee1f45714c4922faa6eb61c0113314dbc/hiredis-3.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c76e751fd1e2f221dec09cdc24040ee486886e943d5d7ffc256e8cf15c75e51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/07/2f4be5e827d5c7d59061f2dfc882ceceb60eb9a263e8eebfbc0093b9c75d/hiredis-3.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7d3880f213b6f14e9c69ce52beffd1748eecc8669698c4782761887273b6e1bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/5e/ee606c694ac086ba28753b02d842868118830bcb1fb47e25091484677bec/hiredis-3.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:87c2b3fe7e7c96eba376506a76e11514e07e848f737b254e0973e4b5c3a491e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/1a/c2afd5ebb556ad06fe8ab99d1917709e3b0c4ee07f503ca31dab8d66ef1e/hiredis-3.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d3cfb4089e96f8f8ee9554da93148a9261aa6612ad2cc202c1a494c7b712e31f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/79/e1f0097a53110622c00c51f747f3edec69e24b74539ff23f68085dc547b4/hiredis-3.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f12018e5c5f866a1c3f7017cb2d88e5c6f9440df2281e48865a2b6c40f247f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/ca/531e287fc5c066d9f39bbc3a6a50ac34c84425c06bf2001af4bd2337037a/hiredis-3.1.0-cp311-cp311-win32.whl", hash = "sha256:107b66ce977bb2dff8f2239e68344360a75d05fed3d9fa0570ac4d3020ce2396" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/e1/c555f03a189624ed600118d39ab775e5d507e521a61db1a1dfa1c20f3d02/hiredis-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f1240bde53d3d1676f0aba61b3661560dc9a681cae24d9de33e650864029aa4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/64/9f9c1648853cd34e52b2af04c26cebb7f086cb4cd8ce056fecedd7664be9/hiredis-3.1.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:f7c7f89e0bc4246115754e2eda078a111282f6d6ecc6fb458557b724fe6f2aac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/18/f70f8366c4abcbb830480d72968502192e422ebd60b7ca5f7739872e78cd/hiredis-3.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:3dbf9163296fa45fbddcfc4c5900f10e9ddadda37117dbfb641e327e536b53e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/a0/bf584a34a8b8e7194c3386700113cd7380a585c3e37b57b45bcf036a3305/hiredis-3.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af46a4be0e82df470f68f35316fa16cd1e134d1c5092fc1082e1aad64cce716d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/90/a709dad5fcfa6a3d0480709fd9e24d1e0ba70cbe4b853a1fe63cf7026207/hiredis-3.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc63d698c43aea500a84d8b083f830c03808b6cf3933ae4d35a27f0a3d881652" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/29/33f943cc874d4cc6269d472b2c8ebb7385008fbde192aa5108d617d99504/hiredis-3.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:676b3d88674134bfaaf70dac181d1790b0f33b3187bfb9da9221e17e0e624f83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/b2/a1315d474ec36c89e68ac8a3a258431b6f266af7bc4a31265a9527e494df/hiredis-3.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aed10d9df1e2fb0011db2713ac64497462e9c2c0208b648c97569da772b959ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/4f/14aca28a24463b92274464000691610eb41a9afab1e16a7a739be496f274/hiredis-3.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b5bd8adfe8742e331a94cccd782bffea251fa70d9a709e71f4510f50794d700" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/8d/e5aa6857a70c0e3ca423973ea27065fa3cf2567d25cc397b649a1d45043e/hiredis-3.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9fc4e35b4afb0af6da55495dd0742ad32ab88150428a6ecdbb3085cbd60714e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/5d/c167de0a8c841cb4ea0e25a8145bbdb7e33b5028eaf905cd0901381f0a83/hiredis-3.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:89b83e76eb00ab0464e7b0752a3ffcb02626e742e9509bc141424a9c3202e8dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/b8/fa7e9ae73237999a5c7eb9f59e6c2198ed65eca5cad948b85e2c82c12cc2/hiredis-3.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:98ebf08c907836b70a8f40e030df8ab6f174dc7f6fa765251d813e89f14069d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/af/6b6db2d29e2455e97cbf7e19bae0ef1a6e5b61c08d42377a3511ef9cc3bb/hiredis-3.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6c840b9cec086328f2ee2cfee0038b5d6bbb514bac7b5e579da6e346eaac056c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/50/c49d53832d71e1fdb1fe7c91a99b2d47043655cb0d535437264dccc19e2e/hiredis-3.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c5c44e9fa6f4462d0330cb5f5d46fa652512fc86b41d4d1974d0356f263e9105" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/47/81992b4b27b59152abf7e279c4adba7a5a0e1d99ccbee674a82c6e65b9bf/hiredis-3.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e665b14ab50aa175cfa306fcb00fffd4e3ff02ceb36ca6a4df00b1246d6a73c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/f6/1ee81c373a2087557c6020bf201b4d27d6aec173c8414c3d06900e91d9bd/hiredis-3.1.0-cp312-cp312-win32.whl", hash = "sha256:bd33db977ac7af97e8d035ffadb163b00546be22e5f1297b2123f5f9bf0f8a21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/67/46d5a8d44812c6293c8088d642e473b0dd9e12478ef539eb4a77df643450/hiredis-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:37aed4aa9348600145e2d019c7be27855e503ecc4906c6976ff2f3b52e3d5d97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/b0/0b4f96f537d259b818e4ee7657616eb6fabc0612eb4150d2253f84e33f8f/hiredis-3.1.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:b87cddd8107487863fed6994de51e5594a0be267b0b19e213694e99cdd614623" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/85/bd6cb6f7645a3803111a4f07fb2b55a23b836725bc8ec74ac7623fe8bef4/hiredis-3.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:d302deff8cb63a7feffc1844e4dafc8076e566bbf10c5aaaf0f4fe791b8a6bd0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/48/b53c5d10d3fd073a2046d096d9d415d61b3564f74b0499ec757ddaf7cddc/hiredis-3.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a018340c073cf88cb635b2bedff96619df2f666018c655e7911f46fa2c1c178" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/a0/f9da8e920c1871edf703dfa05dd6781a3c53e5574cd2e4b38a438053a533/hiredis-3.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1e8ba6414ac1ae536129e18c069f3eb497df5a74e136e3566471620a4fa5f95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/59/82a3625dc9fc77f43b38d272eef8c731e359e535a13b29b83ce220d47f5d/hiredis-3.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a86b9fef256c2beb162244791fdc025aa55f936d6358e86e2020e512fe2e4972" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/aa/66933e4101198f2e2ae379c091fb9a8131cd3dce7a1e6d8fa5ff51244239/hiredis-3.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7acdc68e29a446ad17aadaff19c981a36b3bd8c894c3520412c8a7ab1c3e0de7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/da/e1475f4d51225cbc4b04e3be22ecb6da80a536b747aa4bb263af318d8555/hiredis-3.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7e06baea05de57e1e7548064f505a6964e992674fe61b8f274afe2ac93b6371" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/d7/52dd39b5abb81eb24726934c3b9138cc9a30231fb93da8a3e2f829e3598c/hiredis-3.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35b5fc061c8a0dbfdb440053280504d6aaa8d9726bd4d1d0e1cfcbbdf0d60b73" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/dd/aecfd9f24015b7e892304d6feb888db25b01492f05730f8f45155887de1f/hiredis-3.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c89d2dcb271d24c44f02264233b75d5db8c58831190fa92456a90b87fa17b748" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/77/4a5357b29e4c9f573439246d27cabad470ea4367a60a86f01c2a31c7c63f/hiredis-3.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:aa36688c10a08f626fddcf68c2b1b91b0e90b070c26e550a4151a877f5c2d431" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/5e/b357511490626e9c39b3148612bda945f2cd0c8dcd149f36fd7b9512bff4/hiredis-3.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3982a9c16c1c4bc05a00b65d01ffb8d80ea1a7b6b533be2f1a769d3e989d2c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/82/50c015dcf04ea85a89c4603684da9d95c7850931b5320c02c6f3d7ddd78f/hiredis-3.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d1a6f889514ee2452300c9a06862fceedef22a2891f1c421a27b1ba52ef130b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/10/bd8f39423b0cb9624ccaf08d5e9c04f72dd46e9e9fc82e95cec42a42428d/hiredis-3.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8a45ff7915392a55d9386bb235ea1d1eb9960615f301979f02143fc20036b699" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/77/00b420ad567875e5a4b37a16f1a89fef1a22c6a9e1a12195c77bb5b101dd/hiredis-3.1.0-cp313-cp313-win32.whl", hash = "sha256:539e5bb725b62b76a5319a4e68fc7085f01349abc2316ef3df608ea0883c51d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/04/eaa88433249ddfc282018d3da4198d0b0018e48768e137bfad304aacb1ec/hiredis-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9020fd7e58f489fda6a928c31355add0e665fd6b87b21954e675cf9943eafa32" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/11/13f2af303ed3891ed459527b0183bb743c43eeffd22b8771e7260a0b0281/hiredis-3.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:07ab990d0835f36bf358dbb84db4541ac0a8f533128ec09af8f80a576eef2e88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/3e/0938e733ad08b6cabd1c56d973207769278b9d971fe6d5ed6480232a7b37/hiredis-3.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c54a88eb9d8ebc4e5eefaadbe2102a4f7499f9e413654172f40aefd25350959" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/a7/39d9521519b69056365892a51e2614275f3ae1f149e2c5d9885a909586fe/hiredis-3.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8095ef159896e5999a795b0f80e4d64281301a109e442a8d29cd750ca6bd8303" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/0a/e82918ac75213a47d8fbdcf7f6e2d3fd09a1eeb4e253d6babe47c00602f7/hiredis-3.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f8ca13e2476ffd6d5be4763f5868133506ddcfa5ce54b4dac231ebdc19be6c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/cf/8de09573adcaa0906dd689904e24250561bc792c7f9ae7910f154fbba9b0/hiredis-3.1.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d25aa25c10f966d5415795ed271da84605044dbf436c054966cea5442451b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/ff/e1603c3c6926c1fa6ae85595e983d7206def21e455ee6f4578bbf31c479f/hiredis-3.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4180dc5f646b426e5fa1212e1348c167ee2a864b3a70d56579163d64a847dd1e" }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd" }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, +] + +[[package]] +name = "identify" +version = "2.6.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9b/98/a71ab060daec766acc30fb47dfca219d03de34a70d616a79a38c6066c5bf/identify-2.6.9.tar.gz", hash = "sha256:d40dfe3142a1421d8518e3d3985ef5ac42890683e32306ad614a29490abeb6bf" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/07/ce/0845144ed1f0e25db5e7a79c2354c1da4b5ce392b8966449d5db8dca18f1/identify-2.6.9-py2.py3-none-any.whl", hash = "sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, +] + +[[package]] +name = "loguru" +version = "0.7.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9e/30/d87a423766b24db416a46e9335b9602b054a72b96a88a241f2b09b560fa8/loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/03/0a/4f6fed21aa246c6b49b561ca55facacc2a44b87d65b8b92362a8e99ba202/loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb" }, +] + +[[package]] +name = "mako" +version = "1.3.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/62/4f/ddb1965901bc388958db9f0c991255b2c469349a741ae8c9cd8a562d70a6/mako-1.3.9.tar.gz", hash = "sha256:b5d65ff3462870feec922dbccf38f6efb44e5714d7b593a656be86663d8600ac" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cd/83/de0a49e7de540513f53ab5d2e105321dedeb08a8f5850f0208decf4390ec/Mako-1.3.9-py3-none-any.whl", hash = "sha256:95920acccb578427a9aa38e37a186b1e43156c87260d7ba18ca63aa4c7cbd3a1" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, +] + +[[package]] +name = "msgspec" +version = "0.19.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cf/9b/95d8ce458462b8b71b8a70fa94563b2498b89933689f3a7b8911edfae3d7/msgspec-0.19.0.tar.gz", hash = "sha256:604037e7cd475345848116e89c553aa9a233259733ab51986ac924ab1b976f8e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/13/40/817282b42f58399762267b30deb8ac011d8db373f8da0c212c85fbe62b8f/msgspec-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d8dd848ee7ca7c8153462557655570156c2be94e79acec3561cf379581343259" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/99/bd7ed738c00f223a8119928661167a89124140792af18af513e6519b0d54/msgspec-0.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0553bbc77662e5708fe66aa75e7bd3e4b0f209709c48b299afd791d711a93c36" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/27/322badde18eb234e36d4a14122b89edd4e2973cdbc3da61ca7edf40a1ccd/msgspec-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe2c4bf29bf4e89790b3117470dea2c20b59932772483082c468b990d45fb947" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/65/080509c5774a1592b2779d902a70b5fe008532759927e011f068145a16cb/msgspec-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e87ecfa9795ee5214861eab8326b0e75475c2e68a384002aa135ea2a27d909" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/2e/1c23c6b4ca6f4285c30a39def1054e2bee281389e4b681b5e3711bd5a8c9/msgspec-0.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3c4ec642689da44618f68c90855a10edbc6ac3ff7c1d94395446c65a776e712a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/fe/95f9654518879f3359d1e76bc41189113aa9102452170ab7c9a9a4ee52f6/msgspec-0.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2719647625320b60e2d8af06b35f5b12d4f4d281db30a15a1df22adb2295f633" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/f6/71ca7e87a1fb34dfe5efea8156c9ef59dd55613aeda2ca562f122cd22012/msgspec-0.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:695b832d0091edd86eeb535cd39e45f3919f48d997685f7ac31acb15e0a2ed90" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/d4/2ec2567ac30dab072cce3e91fb17803c52f0a37aab6b0c24375d2b20a581/msgspec-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa77046904db764b0462036bc63ef71f02b75b8f72e9c9dd4c447d6da1ed8f8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/c0/18226e4328897f4f19875cb62bb9259fe47e901eade9d9376ab5f251a929/msgspec-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:047cfa8675eb3bad68722cfe95c60e7afabf84d1bd8938979dd2b92e9e4a9551" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/25/3a4b24d468203d8af90d1d351b77ea3cffb96b29492855cf83078f16bfe4/msgspec-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e78f46ff39a427e10b4a61614a2777ad69559cc8d603a7c05681f5a595ea98f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/2e/db7e189b57901955239f7689b5dcd6ae9458637a9c66747326726c650523/msgspec-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c7adf191e4bd3be0e9231c3b6dc20cf1199ada2af523885efc2ed218eafd011" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/97/7c8895c9074a97052d7e4a1cc1230b7b6e2ca2486714eb12c3f08bb9d284/msgspec-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f04cad4385e20be7c7176bb8ae3dca54a08e9756cfc97bcdb4f18560c3042063" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/61/e892997bcaa289559b4d5869f066a8021b79f4bf8e955f831b095f47a4cd/msgspec-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45c8fb410670b3b7eb884d44a75589377c341ec1392b778311acdbfa55187716" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/3d/71b2dffd3a1c743ffe13296ff701ee503feaebc3f04d0e75613b6563c374/msgspec-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:70eaef4934b87193a27d802534dc466778ad8d536e296ae2f9334e182ac27b6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/5f/a70c24f075e3e7af2fae5414c7048b0e11389685b7f717bb55ba282a34a7/msgspec-0.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f98bd8962ad549c27d63845b50af3f53ec468b6318400c9f1adfe8b092d7b62f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/b0/1b9763938cfae12acf14b682fcf05c92855974d921a5a985ecc197d1c672/msgspec-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43bbb237feab761b815ed9df43b266114203f53596f9b6e6f00ebd79d178cdf2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/81/0c8c93f0b92c97e326b279795f9c5b956c5a97af28ca0fbb9fd86c83737a/msgspec-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cfc033c02c3e0aec52b71710d7f84cb3ca5eb407ab2ad23d75631153fdb1f12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/ef/c5422ce8af73928d194a6606f8ae36e93a52fd5e8df5abd366903a5ca8da/msgspec-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d911c442571605e17658ca2b416fd8579c5050ac9adc5e00c2cb3126c97f73bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/2b/4137bc2ed45660444842d042be2cf5b18aa06efd2cda107cff18253b9653/msgspec-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:757b501fa57e24896cf40a831442b19a864f56d253679f34f260dcb002524a6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/e6/8ad51bdc806aac1dc501e8fe43f759f9ed7284043d722b53323ea421c360/msgspec-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5f0f65f29b45e2816d8bded36e6b837a4bf5fb60ec4bc3c625fa2c6da4124537" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/ef/27dd35a7049c9a4f4211c6cd6a8c9db0a50647546f003a5867827ec45391/msgspec-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:067f0de1c33cfa0b6a8206562efdf6be5985b988b53dd244a8e06f993f27c8c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/cb/2842c312bbe618d8fefc8b9cedce37f773cdc8fa453306546dba2c21fd98/msgspec-0.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f12d30dd6266557aaaf0aa0f9580a9a8fbeadfa83699c487713e355ec5f0bd86" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/95/c40b01b93465e1a5f3b6c7d91b10fb574818163740cc3acbe722d1e0e7e4/msgspec-0.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82b2c42c1b9ebc89e822e7e13bbe9d17ede0c23c187469fdd9505afd5a481314" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/f0/5b764e066ce9aba4b70d1db8b087ea66098c7c27d59b9dd8a3532774d48f/msgspec-0.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19746b50be214a54239aab822964f2ac81e38b0055cca94808359d779338c10e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/87/bc14f49bc95c4cb0dd0a8c56028a67c014ee7e6818ccdce74a4862af259b/msgspec-0.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60ef4bdb0ec8e4ad62e5a1f95230c08efb1f64f32e6e8dd2ced685bcc73858b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/2f/2b1c2b056894fbaa975f68f81e3014bb447516a8b010f1bed3fb0e016ed7/msgspec-0.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac7f7c377c122b649f7545810c6cd1b47586e3aa3059126ce3516ac7ccc6a6a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/5a/4cd408d90d1417e8d2ce6a22b98a6853c1b4d7cb7669153e4424d60087f6/msgspec-0.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5bc1472223a643f5ffb5bf46ccdede7f9795078194f14edd69e3aab7020d327" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00" }, +] + +[[package]] +name = "numpy" +version = "2.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2e/19/d7c972dfe90a353dbd3efbbe1d14a5951de80c99c9dc1b93cd998d51dc0f/numpy-2.3.1.tar.gz", hash = "sha256:1ec9ae20a4226da374362cca3c62cd753faf2f951440b0e3b98e93c235441d2b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b0/c7/87c64d7ab426156530676000c94784ef55676df2f13b2796f97722464124/numpy-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ea9e48336a402551f52cd8f593343699003d2353daa4b72ce8d34f66b722070" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/0e/0966c2f44beeac12af8d836e5b5f826a407cf34c45cb73ddcdfce9f5960b/numpy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ccb7336eaf0e77c1635b232c141846493a588ec9ea777a7c24d7166bb8533ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/31/6e35a247acb1bfc19226791dfc7d4c30002cd4e620e11e58b0ddf836fe52/numpy-2.3.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bb3a4a61e1d327e035275d2a993c96fa786e4913aa089843e6a2d9dd205c66a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/25/93b621219bb6f5a2d4e713a824522c69ab1f06a57cd571cda70e2e31af44/numpy-2.3.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:e344eb79dab01f1e838ebb67aab09965fb271d6da6b00adda26328ac27d4a66e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/60/6b06ed98d11fb32e27fb59468b42383f3877146d3ee639f733776b6ac596/numpy-2.3.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:467db865b392168ceb1ef1ffa6f5a86e62468c43e0cfb4ab6da667ede10e58db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/c9/9bec03675192077467a9c7c2bdd1f2e922bd01d3a69b15c3a0fdcd8548f6/numpy-2.3.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:afed2ce4a84f6b0fc6c1ce734ff368cbf5a5e24e8954a338f3bdffa0718adffb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/e2/5756a00cabcf50a3f527a0c968b2b4881c62b1379223931853114fa04cda/numpy-2.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0025048b3c1557a20bc80d06fdeb8cc7fc193721484cca82b2cfa072fec71a93" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/86/a471f65f0a86f1ca62dcc90b9fa46174dd48f50214e5446bc16a775646c5/numpy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5ee121b60aa509679b682819c602579e1df14a5b07fe95671c8849aad8f2115" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/a6/482a53e469b32be6500aaf61cfafd1de7a0b0d484babf679209c3298852e/numpy-2.3.1-cp311-cp311-win32.whl", hash = "sha256:a8b740f5579ae4585831b3cf0e3b0425c667274f82a484866d2adf9570539369" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/fb/bb613f4122c310a13ec67585c70e14b03bfc7ebabd24f4d5138b97371d7c/numpy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:d4580adadc53311b163444f877e0789f1c8861e2698f6b2a4ca852fda154f3ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/58/2d842825af9a0c041aca246dc92eb725e1bc5e1c9ac89712625db0c4e11c/numpy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:ec0bdafa906f95adc9a0c6f26a4871fa753f25caaa0e032578a30457bff0af6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/56/71ad5022e2f63cfe0ca93559403d0edef14aea70a841d640bd13cdba578e/numpy-2.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2959d8f268f3d8ee402b04a9ec4bb7604555aeacf78b360dc4ec27f1d508177d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/65/2db52ba049813670f7f987cc5db6dac9be7cd95e923cc6832b3d32d87cef/numpy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:762e0c0c6b56bdedfef9a8e1d4538556438288c4276901ea008ae44091954e29" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/dd/28fa3c17b0e751047ac928c1e1b6990238faad76e9b147e585b573d9d1bd/numpy-2.3.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:867ef172a0976aaa1f1d1b63cf2090de8b636a7674607d514505fb7276ab08fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/fc/84ea0cba8e760c4644b708b6819d91784c290288c27aca916115e3311d17/numpy-2.3.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:4e602e1b8682c2b833af89ba641ad4176053aaa50f5cacda1a27004352dde943" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/b2/512b0c2ddec985ad1e496b0bd853eeb572315c0f07cd6997473ced8f15e2/numpy-2.3.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8e333040d069eba1652fb08962ec5b76af7f2c7bce1df7e1418c8055cf776f25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/45/c51cb248e679a6c6ab14b7a8e3ead3f4a3fe7425fc7a6f98b3f147bec532/numpy-2.3.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e7cbf5a5eafd8d230a3ce356d892512185230e4781a361229bd902ff403bc660" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/ff/feb4be2e5c09a3da161b412019caf47183099cbea1132fd98061808c2df2/numpy-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1b8f26d1086835f442286c1d9b64bb3974b0b1e41bb105358fd07d20872952" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/6d/ceafe87587101e9ab0d370e4f6e5f3f3a85b9a697f2318738e5e7e176ce3/numpy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ee8340cb48c9b7a5899d1149eece41ca535513a9698098edbade2a8e7a84da77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/19/0fb49a3ea088be691f040c9bf1817e4669a339d6e98579f91859b902c636/numpy-2.3.1-cp312-cp312-win32.whl", hash = "sha256:e772dda20a6002ef7061713dc1e2585bc1b534e7909b2030b5a46dae8ff077ab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/3e/e28f4c1dd9e042eb57a3eb652f200225e311b608632bc727ae378623d4f8/numpy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cfecc7822543abdea6de08758091da655ea2210b8ffa1faf116b940693d3df76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/a8/8a5e9079dc722acf53522b8f8842e79541ea81835e9b5483388701421073/numpy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:7be91b2239af2658653c5bb6f1b8bccafaf08226a258caf78ce44710a0160d30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/bd/35ad97006d8abff8631293f8ea6adf07b0108ce6fec68da3c3fcca1197f2/numpy-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25a1992b0a3fdcdaec9f552ef10d8103186f5397ab45e2d25f8ac51b1a6b97e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/4f/df5923874d8095b6062495b39729178eef4a922119cee32a12ee1bd4664c/numpy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dea630156d39b02a63c18f508f85010230409db5b2927ba59c8ba4ab3e8272e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/0f/a1f269b125806212a876f7efb049b06c6f8772cf0121139f97774cd95626/numpy-2.3.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bada6058dd886061f10ea15f230ccf7dfff40572e99fef440a4a857c8728c9c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/63/a7f7fd5f375b0361682f6ffbf686787e82b7bbd561268e4f30afad2bb3c0/numpy-2.3.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:a894f3816eb17b29e4783e5873f92faf55b710c2519e5c351767c51f79d8526d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/0d/1854a4121af895aab383f4aa233748f1df4671ef331d898e32426756a8a6/numpy-2.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:18703df6c4a4fee55fd3d6e5a253d01c5d33a295409b03fda0c86b3ca2ff41a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/30/af1b277b443f2fb08acf1c55ce9d68ee540043f158630d62cef012750f9f/numpy-2.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5902660491bd7a48b2ec16c23ccb9124b8abfd9583c5fdfa123fe6b421e03de1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/ec/3b68220c277e463095342d254c61be8144c31208db18d3fd8ef02712bcd6/numpy-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36890eb9e9d2081137bd78d29050ba63b8dab95dff7912eadf1185e80074b2a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/2b/4014f2bcc4404484021c74d4c5ee8eb3de7e3f7ac75f06672f8dcf85140a/numpy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a780033466159c2270531e2b8ac063704592a0bc62ec4a1b991c7c40705eb0e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/8d/2ddd6c9b30fcf920837b8672f6c65590c7d92e43084c25fc65edc22e93ca/numpy-2.3.1-cp313-cp313-win32.whl", hash = "sha256:39bff12c076812595c3a306f22bfe49919c5513aa1e0e70fac756a0be7c2a2b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/c8/beaba449925988d415efccb45bf977ff8327a02f655090627318f6398c7b/numpy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d5ee6eec45f08ce507a6570e06f2f879b374a552087a4179ea7838edbcbfa42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/c3/5c0c575d7ec78c1126998071f58facfc124006635da75b090805e642c62e/numpy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c4d9e0a8368db90f93bd192bfa771ace63137c3488d198ee21dfb8e7771916e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/19/a029cd335cf72f79d2644dcfc22d90f09caa86265cbbde3b5702ccef6890/numpy-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b0b5397374f32ec0649dd98c652a1798192042e715df918c20672c62fb52d4b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/91/8ea8894406209107d9ce19b66314194675d31761fe2cb3c84fe2eeae2f37/numpy-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c5bdf2015ccfcee8253fb8be695516ac4457c743473a43290fd36eba6a1777eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/7f/06187b0066eefc9e7ce77d5f2ddb4e314a55220ad62dd0bfc9f2c44bac14/numpy-2.3.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d70f20df7f08b90a2062c1f07737dd340adccf2068d0f1b9b3d56e2038979fee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/ec/a926c293c605fa75e9cfb09f1e4840098ed46d2edaa6e2152ee35dc01ed3/numpy-2.3.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:2fb86b7e58f9ac50e1e9dd1290154107e47d1eef23a0ae9145ded06ea606f992" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/62/d68e52fb6fde5586650d4c0ce0b05ff3a48ad4df4ffd1b8866479d1d671d/numpy-2.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:23ab05b2d241f76cb883ce8b9a93a680752fbfcbd51c50eff0b88b979e471d8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/ec/b74d3f2430960044bdad6900d9f5edc2dc0fb8bf5a0be0f65287bf2cbe27/numpy-2.3.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ce2ce9e5de4703a673e705183f64fd5da5bf36e7beddcb63a25ee2286e71ca48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/15/def96774b9d7eb198ddadfcbd20281b20ebb510580419197e225f5c55c3e/numpy-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c4913079974eeb5c16ccfd2b1f09354b8fed7e0d6f2cab933104a09a6419b1ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/57/c3203974762a759540c6ae71d0ea2341c1fa41d84e4971a8e76d7141678a/numpy-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:010ce9b4f00d5c036053ca684c77441f2f2c934fd23bee058b4d6f196efd8280" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/8a/ccdf201457ed8ac6245187850aff4ca56a79edbea4829f4e9f14d46fa9a5/numpy-2.3.1-cp313-cp313t-win32.whl", hash = "sha256:6269b9edfe32912584ec496d91b00b6d34282ca1d07eb10e82dfc780907d6c2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/7e/7f431d8bd8eb7e03d79294aed238b1b0b174b3148570d03a8a8a8f6a0da9/numpy-2.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2a809637460e88a113e186e87f228d74ae2852a2e0c44de275263376f17b5bdc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/ca/af82bf0fad4c3e573c6930ed743b5308492ff19917c7caaf2f9b6f9e2e98/numpy-2.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:eccb9a159db9aed60800187bc47a6d3451553f0e1b08b068d8b277ddfbb9b244" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/34/facc13b9b42ddca30498fc51f7f73c3d0f2be179943a4b4da8686e259740/numpy-2.3.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ad506d4b09e684394c42c966ec1527f6ebc25da7f4da4b1b056606ffe446b8a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/b6/41b705d9dbae04649b529fc9bd3387664c3281c7cd78b404a4efe73dcc45/numpy-2.3.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ebb8603d45bc86bbd5edb0d63e52c5fd9e7945d3a503b77e486bd88dde67a19b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/b4/fe3ac1902bff7a4934a22d49e1c9d71a623204d654d4cc43c6e8fe337fcb/numpy-2.3.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:15aa4c392ac396e2ad3d0a2680c0f0dee420f9fed14eef09bdb9450ee6dcb7b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/ee/89bedf69c36ace1ac8f59e97811c1f5031e179a37e4821c3a230bf750142/numpy-2.3.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c6e0bf9d1a2f50d2b65a7cf56db37c095af17b59f6c132396f7c6d5dd76484df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/08/e00e7070ede29b2b176165eba18d6f9784d5349be3c0c1218338e79c27fd/numpy-2.3.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eabd7e8740d494ce2b4ea0ff05afa1b7b291e978c0ae075487c51e8bd93c0c68" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/6b/1c6b515a83d5564b1698a61efa245727c8feecf308f4091f565988519d20/numpy-2.3.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e610832418a2bc09d974cc9fecebfa51e9532d6190223bc5ef6a7402ebf3b5cb" }, +] + +[[package]] +name = "orjson" +version = "3.10.16" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/98/c7/03913cc4332174071950acf5b0735463e3f63760c80585ef369270c2b372/orjson-3.10.16.tar.gz", hash = "sha256:d2aaa5c495e11d17b9b93205f5fa196737ee3202f000aaebf028dc9a73750f10" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9d/a6/22cb9b03baf167bc2d659c9e74d7580147f36e6a155e633801badfd5a74d/orjson-3.10.16-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4cb473b8e79154fa778fb56d2d73763d977be3dcc140587e07dbc545bbfc38f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/ce/3e68cc33020a6ebd8f359b8628b69d2132cd84fea68155c33057e502ee51/orjson-3.10.16-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:622a8e85eeec1948690409a19ca1c7d9fd8ff116f4861d261e6ae2094fe59a00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/12/63bee7764ce12052f7c1a1393ce7f26dc392c93081eb8754dd3dce9b7c6b/orjson-3.10.16-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c682d852d0ce77613993dc967e90e151899fe2d8e71c20e9be164080f468e370" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/d5/2998c2f319adcd572f2b03ba2083e8176863d1055d8d713683ddcf927b71/orjson-3.10.16-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c520ae736acd2e32df193bcff73491e64c936f3e44a2916b548da048a48b46b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/03/88c236ae307bd0604623204d4a835e15fbf9c75b8535c8f13ef45abd413f/orjson-3.10.16-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:134f87c76bfae00f2094d85cfab261b289b76d78c6da8a7a3b3c09d362fd1e06" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/ba/3e256ddfeb364f98fd6ac65774844090d356158b2d1de8998db2bf984503/orjson-3.10.16-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b59afde79563e2cf37cfe62ee3b71c063fd5546c8e662d7fcfc2a3d5031a5c4c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/71/73a1214bd27baa2ea5184fff4aa6193a114dfb0aa5663dad48fe63e8cd29/orjson-3.10.16-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:113602f8241daaff05d6fad25bd481d54c42d8d72ef4c831bb3ab682a54d9e15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/ac/0b2f41c0a1e8c095439d0fab3b33103cf41a39be8e6aa2c56298a6034259/orjson-3.10.16-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4fc0077d101f8fab4031e6554fc17b4c2ad8fdbc56ee64a727f3c95b379e31da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/ca/7524c7b0bc815d426ca134dab54cad519802287b808a3846b047a5b2b7a3/orjson-3.10.16-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9c6bf6ff180cd69e93f3f50380224218cfab79953a868ea3908430bcfaf9cb5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/1d/3ae2367c255276bf16ff7e1b210dd0af18bc8da20c4e4295755fc7de1268/orjson-3.10.16-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5673eadfa952f95a7cd76418ff189df11b0a9c34b1995dff43a6fdbce5d63bf4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/2d/8eb10b6b1d30bb69c35feb15e5ba5ac82466cf743d562e3e8047540efd2f/orjson-3.10.16-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5fe638a423d852b0ae1e1a79895851696cb0d9fa0946fdbfd5da5072d9bb9551" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/42/f043717930cb2de5fbebe47f308f101bed9ec2b3580b1f99c8284b2f5fe8/orjson-3.10.16-cp310-cp310-win32.whl", hash = "sha256:33af58f479b3c6435ab8f8b57999874b4b40c804c7a36b5cc6b54d8f28e1d3dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/99/795ad7282b425b9fddcfb8a31bded5dcf84dba78ecb1e7ae716e84e794da/orjson-3.10.16-cp310-cp310-win_amd64.whl", hash = "sha256:0338356b3f56d71293c583350af26f053017071836b07e064e92819ecf1aa055" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/29/43f91a5512b5d2535594438eb41c5357865fd5e64dec745d90a588820c75/orjson-3.10.16-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44fcbe1a1884f8bc9e2e863168b0f84230c3d634afe41c678637d2728ea8e739" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/36/2a72d55e266473c19a86d97b7363bb8bf558ab450f75205689a287d5ce61/orjson-3.10.16-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78177bf0a9d0192e0b34c3d78bcff7fe21d1b5d84aeb5ebdfe0dbe637b885225" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/ad/f86d6f55c1a68b57ff6ea7966bce5f4e5163f2e526ddb7db9fc3c2c8d1c4/orjson-3.10.16-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12824073a010a754bb27330cad21d6e9b98374f497f391b8707752b96f72e741" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/8b/d18f2711493a809f3082a88fda89342bc8e16767743b909cd3c34989fba3/orjson-3.10.16-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddd41007e56284e9867864aa2f29f3136bb1dd19a49ca43c0b4eda22a579cf53" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/dc/ce025f002f8e0749e3f057c4d773a4d4de32b7b4c1fc5a50b429e7532586/orjson-3.10.16-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0877c4d35de639645de83666458ca1f12560d9fa7aa9b25d8bb8f52f61627d14" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/1b/cf9df85852b91160029d9f26014230366a2b4deb8cc51fabe68e250a8c1a/orjson-3.10.16-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9a09a539e9cc3beead3e7107093b4ac176d015bec64f811afb5965fce077a03c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/18/5b1e1e995bffad49dc4311a0bdfd874bc6f135fd20f0e1f671adc2c9910e/orjson-3.10.16-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31b98bc9b40610fec971d9a4d67bb2ed02eec0a8ae35f8ccd2086320c28526ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/eb/467f25b580e942fcca1344adef40633b7f05ac44a65a63fc913f9a805d58/orjson-3.10.16-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0ce243f5a8739f3a18830bc62dc2e05b69a7545bafd3e3249f86668b2bcd8e50" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/4b/9d10888038975cb375982e9339d9495bac382d5c976c500b8d6f2c8e2e4e/orjson-3.10.16-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:64792c0025bae049b3074c6abe0cf06f23c8e9f5a445f4bab31dc5ca23dbf9e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/e2/cfbcfcc4fbe619e0ca9bdbbfccb2d62b540bbfe41e0ee77d44a628594f59/orjson-3.10.16-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ea53f7e68eec718b8e17e942f7ca56c6bd43562eb19db3f22d90d75e13f0431d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/d6/627a1b00569be46173007c11dde3da4618c9bfe18409325b0e3e2a82fe29/orjson-3.10.16-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a741ba1a9488c92227711bde8c8c2b63d7d3816883268c808fbeada00400c164" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/7b/a73c67b505021af845b9f05c7c848793258ea141fa2058b52dd9b067c2b4/orjson-3.10.16-cp311-cp311-win32.whl", hash = "sha256:c7ed2c61bb8226384c3fdf1fb01c51b47b03e3f4536c985078cccc2fd19f1619" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/22/5e8217c48d68c0adbfb181e749d6a733761074e598b083c69a1383d18147/orjson-3.10.16-cp311-cp311-win_amd64.whl", hash = "sha256:cd67d8b3e0e56222a2e7b7f7da9031e30ecd1fe251c023340b9f12caca85ab60" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/15/67ce9d4c959c83f112542222ea3b9209c1d424231d71d74c4890ea0acd2b/orjson-3.10.16-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6d3444abbfa71ba21bb042caa4b062535b122248259fdb9deea567969140abca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/2c/1426b06f30a1b9ada74b6f512c1ddf9d2760f53f61cdb59efeb9ad342133/orjson-3.10.16-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:30245c08d818fdcaa48b7d5b81499b8cae09acabb216fe61ca619876b128e184" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/88/18d26130954bc73bee3be10f95371ea1dfb8679e0e2c46b0f6d8c6289402/orjson-3.10.16-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0ba1d0baa71bf7579a4ccdcf503e6f3098ef9542106a0eca82395898c8a500a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/f9/6d8b64fcd58fae072e80ee7981be8ba0d7c26ace954e5cd1d027fc80518f/orjson-3.10.16-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb0beefa5ef3af8845f3a69ff2a4aa62529b5acec1cfe5f8a6b4141033fd46ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/3f/2513fd5bc786f40cd12af569c23cae6381aeddbefeed2a98f0a666eb5d0d/orjson-3.10.16-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6daa0e1c9bf2e030e93c98394de94506f2a4d12e1e9dadd7c53d5e44d0f9628e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/42/b0e7b36720f5ab722b48e8ccf06514d4f769358dd73c51abd8728ef58d0b/orjson-3.10.16-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9da9019afb21e02410ef600e56666652b73eb3e4d213a0ec919ff391a7dd52aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/a8/d220afb8a439604be74fc755dbc740bded5ed14745ca536b304ed32eb18a/orjson-3.10.16-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:daeb3a1ee17b69981d3aae30c3b4e786b0f8c9e6c71f2b48f1aef934f63f38f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/88/7e41e9883c00f84f92fe357a8371edae816d9d7ef39c67b5106960c20389/orjson-3.10.16-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fed80eaf0e20a31942ae5d0728849862446512769692474be5e6b73123a23b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/ca/61116095307ad0be828ea26093febaf59e38596d84a9c8d765c3c5e4934f/orjson-3.10.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73390ed838f03764540a7bdc4071fe0123914c2cc02fb6abf35182d5fd1b7a42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/1b/09493cf7d801505f094c9295f79c98c1e0af2ac01c7ed8d25b30fcb19ada/orjson-3.10.16-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a22bba012a0c94ec02a7768953020ab0d3e2b884760f859176343a36c01adf87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/02/125d7bbd7f7a500190ddc8ae5d2d3c39d87ed3ed28f5b37cfe76962c678d/orjson-3.10.16-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5385bbfdbc90ff5b2635b7e6bebf259652db00a92b5e3c45b616df75b9058e88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/09/7658a9e3e793d5b3b00598023e0fb6935d0e7bbb8ff72311c5415a8ce677/orjson-3.10.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:02c6279016346e774dd92625d46c6c40db687b8a0d685aadb91e26e46cc33e1e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/87/32b7a4831e909d347278101a48d4cf9f3f25901b2295e7709df1651f65a1/orjson-3.10.16-cp312-cp312-win32.whl", hash = "sha256:7ca55097a11426db80f79378e873a8c51f4dde9ffc22de44850f9696b7eb0e8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/ce/81a27e7b439b807bd393585271364cdddf50dc281fc57c4feef7ccb186a6/orjson-3.10.16-cp312-cp312-win_amd64.whl", hash = "sha256:86d127efdd3f9bf5f04809b70faca1e6836556ea3cc46e662b44dab3fe71f3d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/b9/ff6aa28b8c86af9526160905593a2fe8d004ac7a5e592ee0b0ff71017511/orjson-3.10.16-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:148a97f7de811ba14bc6dbc4a433e0341ffd2cc285065199fb5f6a98013744bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/81/6d92a586149b52684ab8fd70f3623c91d0e6a692f30fd8c728916ab2263c/orjson-3.10.16-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:1d960c1bf0e734ea36d0adc880076de3846aaec45ffad29b78c7f1b7962516b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/88/b72443f4793d2e16039ab85d0026677932b15ab968595fb7149750d74134/orjson-3.10.16-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a318cd184d1269f68634464b12871386808dc8b7c27de8565234d25975a7a137" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/3c/72a22d4b28c076c4016d5a52bd644a8e4d849d3bb0373d9e377f9e3b2250/orjson-3.10.16-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df23f8df3ef9223d1d6748bea63fca55aae7da30a875700809c500a05975522b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/a2/f1259561bdb6ad7061ff1b95dab082fe32758c4bc143ba8d3d70831f0a06/orjson-3.10.16-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b94dda8dd6d1378f1037d7f3f6b21db769ef911c4567cbaa962bb6dc5021cf90" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/af/c7583c4b34f33d8b8b90cfaab010ff18dd64e7074cc1e117a5f1eff20dcf/orjson-3.10.16-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f12970a26666a8775346003fd94347d03ccb98ab8aa063036818381acf5f523e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/59/d7fc7fbdd3d4a64c2eae4fc7341a5aa39cf9549bd5e2d7f6d3c07f8b715b/orjson-3.10.16-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15a1431a245d856bd56e4d29ea0023eb4d2c8f71efe914beb3dee8ab3f0cd7fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/0e/3bd8f2197d27601f16b4464ae948826da2bcf128af31230a9dbbad7ceb57/orjson-3.10.16-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c83655cfc247f399a222567d146524674a7b217af7ef8289c0ff53cfe8db09f0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/a8/351fd87b664b02f899f9144d2c3dc848b33ac04a5df05234cbfb9e2a7540/orjson-3.10.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fa59ae64cb6ddde8f09bdbf7baf933c4cd05734ad84dcf4e43b887eb24e37652" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/b0/a6d42a7d412d867c60c0337d95123517dd5a9370deea705ea1be0f89389e/orjson-3.10.16-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ca5426e5aacc2e9507d341bc169d8af9c3cbe88f4cd4c1cf2f87e8564730eb56" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/ec/7572cd4e20863f60996f3f10bc0a6da64a6fd9c35954189a914cec0b7377/orjson-3.10.16-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6fd5da4edf98a400946cd3a195680de56f1e7575109b9acb9493331047157430" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/19/ceb9e8fed5403b2e76a8ac15f581b9d25780a3be3c9b3aa54b7777a210d5/orjson-3.10.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:980ecc7a53e567169282a5e0ff078393bac78320d44238da4e246d71a4e0e8f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/78/a78bb810f3786579dbbbd94768284cbe8f2fd65167cd7020260679665c17/orjson-3.10.16-cp313-cp313-win32.whl", hash = "sha256:28f79944dd006ac540a6465ebd5f8f45dfdf0948ff998eac7a908275b4c1add6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/9c/b66ce9245ff319df2c3278acd351a3f6145ef34b4a2d7f4b0f739368370f/orjson-3.10.16-cp313-cp313-win_amd64.whl", hash = "sha256:fe0a145e96d51971407cb8ba947e63ead2aa915db59d6631a355f5f2150b56b7" }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759" }, +] + +[[package]] +name = "path" +version = "16.14.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/46/30/1e1272907a0fab069a4290c3410066514b84d6f7df0723be5e138c5861f9/path-16.14.0.tar.gz", hash = "sha256:dbaaa7efd4602fd6ba8d82890dc7823d69e5de740a6e842d9919b0faaf2b6a8e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/65/9a/25956c1e6ac785f9e4ffcd5e4c82f4b055696e20aa5331cc8386165b61e0/path-16.14.0-py3-none-any.whl", hash = "sha256:8ee37703cbdc7cc83835ed4ecc6b638226fb2b43b7b45f26b620589981a109a5" }, +] + +[[package]] +name = "pgvector" +version = "0.4.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://mirrors.aliyun.com/pypi/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.1", source = { registry = "https://mirrors.aliyun.com/pypi/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/44/43/9a0fb552ab4fd980680c2037962e331820f67585df740bedc4a2b50faf20/pgvector-0.4.1.tar.gz", hash = "sha256:83d3a1c044ff0c2f1e95d13dfb625beb0b65506cfec0941bfe81fd0ad44f4003" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/bf/21/b5735d5982892c878ff3d01bb06e018c43fc204428361ee9fc25a1b2125c/pgvector-0.4.1-py3-none-any.whl", hash = "sha256:34bb4e99e1b13d08a2fe82dda9f860f15ddcd0166fbb25bffe15821cbfeb7362" }, +] + +[[package]] +name = "pillow" +version = "9.5.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/00/d5/4903f310765e0ff2b8e91ffe55031ac6af77d982f0156061e20a4d1a8b2d/Pillow-9.5.0.tar.gz", hash = "sha256:bf548479d336726d7a0eceb6e767e179fbde37833ae42794602631a070d630f1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1b/bc/cff591742feea45f88a3b8a83f7cab4a1dcdb4bcdfc51a06d92f96c81165/Pillow-9.5.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:ace6ca218308447b9077c14ea4ef381ba0b67ee78d64046b3f19cf4e1139ad16" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/06/de304914ecd2c911939a28579546bd4d9b6ae0b3c07ce5fe9bd7d100eb34/Pillow-9.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3d403753c9d5adc04d4694d35cf0391f0f3d57c8e0030aac09d7678fa8030aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/57/7864b6a22acb5f1d4b70af8c92cbd5e3af25f4d5869c24cd8074ca1f3593/Pillow-9.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ba1b81ee69573fe7124881762bb4cd2e4b6ed9dd28c9c60a632902fe8db8b38" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/88/46a35f690ee4f8b08aef5fdb47f63d29c34f6874834155e52bf4456d9566/Pillow-9.5.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe7e1c262d3392afcf5071df9afa574544f28eac825284596ac6db56e6d11062" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/1d/26a56ed1deae695a8c7d13fb514284ba8b9fd62bab9ebe6d6b474523b8b0/Pillow-9.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f36397bf3f7d7c6a3abdea815ecf6fd14e7fcd4418ab24bae01008d8d8ca15e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/d22b0fac821a14572fdb9a8015b2bf19ee81eaa560ea25a6772760c86a30/Pillow-9.5.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:252a03f1bdddce077eff2354c3861bf437c892fb1832f75ce813ee94347aa9b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/6b/d3c35d207c9c0b6c2f855420f62e64ef43d348e8c797ad1c32b9f2106a19/Pillow-9.5.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:85ec677246533e27770b0de5cf0f9d6e4ec0c212a1f89dfc941b64b21226009d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/6a/a7df39c502caeadd942d8bf97bc2fdfc819fbdc7499a2ab05e7db43611ac/Pillow-9.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b416f03d37d27290cb93597335a2f85ed446731200705b22bb927405320de903" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/ad/d29c8c48498da680521665b8483beb78a9343269bbd0730970e9396b01f0/Pillow-9.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1781a624c229cb35a2ac31cc4a77e28cafc8900733a864870c49bfeedacd106a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/54/9d7f01fd3fe4069c88827728646e3c8f1aff0995e8422d841b38f034f39a/Pillow-9.5.0-cp310-cp310-win32.whl", hash = "sha256:8507eda3cd0608a1f94f58c64817e83ec12fa93a9436938b191b80d9e4c0fc44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/14/0030e542f2acfea43635e55584c114e6cfd94d342393a5f71f74c172dc35/Pillow-9.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:d3c6b54e304c60c4181da1c9dadf83e4a54fd266a99c70ba646a9baa626819eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/a8/3c2d737d856eb9cd8c18e78f6fe0ed08a2805bded74cbb0455584859023b/Pillow-9.5.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:7ec6f6ce99dab90b52da21cf0dc519e21095e332ff3b399a357c187b1a5eee32" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/15/310cde63cb15a091de889ded26281924cf9cfa5c000b36b06bd0c7f50261/Pillow-9.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:560737e70cb9c6255d6dcba3de6578a9e2ec4b573659943a5e7e4af13f298f5c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/66/20db69c0361902a2f6ee2086d3e83c70133e3fb4cb31470e59a8ed37184e/Pillow-9.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96e88745a55b88a7c64fa49bceff363a1a27d9a64e04019c2281049444a571e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/a8/ff526cdec6b56eb20c992e7083f02c8065049ed1e62fbc159390d7a3dd5e/Pillow-9.5.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9c206c29b46cfd343ea7cdfe1232443072bbb270d6a46f59c259460db76779a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/70/e9a45a2e9c58c23e023fcda5af9686f5b42c718cc9bc86194e0025cf0ec5/Pillow-9.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfcc2c53c06f2ccb8976fb5c71d448bdd0a07d26d8e07e321c103416444c7ad1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/a5/ee306d6cc53c9a30c23ba2313b43b67fdf76c611ca5afd0cdd62922cbd3e/Pillow-9.5.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a0f9bb6c80e6efcde93ffc51256d5cfb2155ff8f78292f074f60f9e70b942d99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/59/e6bd2c3715ace343d9739276ceed79657fe116923238d102cf731ab463dd/Pillow-9.5.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8d935f924bbab8f0a9a28404422da8af4904e36d5c33fc6f677e4c4485515625" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/6d/9beb596ba5a5e61081c843187bcdbb42a5c9a9ef552751b554894247da7a/Pillow-9.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fed1e1cf6a42577953abbe8e6cf2fe2f566daebde7c34724ec8803c4c0cda579" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/e4/de633d85be3b3c770c554a37a89e8273069bd19c34b15a419c2795600310/Pillow-9.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c1170d6b195555644f0616fd6ed929dfcf6333b8675fcca044ae5ab110ded296" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/a0/e410f655300932308e70e883dd60c0c51e6f74bed138641ea9193e64fd7c/Pillow-9.5.0-cp311-cp311-win32.whl", hash = "sha256:54f7102ad31a3de5666827526e248c3530b3a33539dbda27c6843d19d72644ec" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/02/7729c8aecbc525b560c7eb283ffa34c6f5a6d0ed6d1339570c65a3e63088/Pillow-9.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfa4561277f677ecf651e2b22dc43e8f5368b74a25a8f7d1d4a3a243e573f2d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/8b/d38cc68796be4ac238db327682a1acfbc5deccf64a150aa44ee1efbaafae/Pillow-9.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:965e4a05ef364e7b973dd17fc765f42233415974d773e82144c9bbaaaea5d089" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/38/b7bcbab3bfe1946ba9cf71c1fa03e541b498069457be49eadcdc229412ef/Pillow-9.5.0-cp312-cp312-win32.whl", hash = "sha256:22baf0c3cf0c7f26e82d6e1adf118027afb325e703922c8dfc1d5d0156bb2eeb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/8a/f4cf3f32bc554f9260b645ea1151449ac13525796d3d1a42076d75945d8d/Pillow-9.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:432b975c009cf649420615388561c0ce7cc31ce9b2e374db659ee4f7d57a1f8b" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7a/81/331257dbf2801cdb82105306042f7a1637cc752f65f2bb688188e0de5f0b/psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/9a/7f4f2f031010bbfe6a02b4a15c01e12eb6b9b7b358ab33229f28baadbfc1/psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/57/8ddd4b374fa811a0b0a0f49b6abad1cde9cb34df73ea3348cc283fcd70b4/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/66/d1e52c20d283f1f3a8e7e5c1e06851d432f123ef57b13043b4f9b21ffa1f/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/cb/592d44a9546aba78f8a1249021fe7c59d3afb8a0ba51434d6610cc3462b6/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/33/c8548560b94b7617f203d7236d6cdf36fe1a5a3645600ada6efd79da946f/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/0e/c2da0db5bea88a3be52307f88b75eec72c4de62814cbe9ee600c29c06334/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/d7/774afa1eadb787ddf41aab52d4c62785563e29949613c958955031408ae6/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/ed/440dc3f5991a8c6172a1cde44850ead0e483a375277a1aef7cfcec00af07/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/be/2cc8f4282898306732d2ae7b7378ae14e8df3c1231b53579efa056aae887/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/12/fb8e4f485d98c570e00dad5800e9a2349cfe0f71a767c856857160d343a5/psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/4f/217cd2471ecf45d82905dd09085e049af8de6cfdc008b6663c3226dc1c98/psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/8f/9feb01291d0d7a0a4c6a6bab24094135c2b59c6a81943752f632c75896d6/psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/30/346e4683532011561cd9c8dfeac6a8153dd96452fee0b12666058ab7893c/psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/6e/4efebe76f76aee7ec99166b6c023ff8abdc4e183f7b70913d7c047701b79/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/fd/ff83313f86b50f7ca089b161b8e0a22bb3c319974096093cd50680433fdb/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/c4/bfadd202dcda8333a7ccafdc51c541dbdfce7c2c7cda89fa2374455d795f/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/f1/09f45ac25e704ac954862581f9f9ae21303cc5ded3d0b775532b407f0e90/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/2e/9beaea078095cc558f215e38f647c7114987d9febfc25cb2beed7c3582a5/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/9e/ef93c5d93f3dc9fc92786ffab39e323b9aed066ba59fdc34cf85e2722271/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/f0/049e9631e3268fe4c5a387f6fc27e267ebe199acf1bc1bc9cbde4bd6916c/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/9a/bcb8773b88e45fb5a5ea8339e2104d82c863a3b8558fbb2aadfe66df86b3/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/6b/144336a9bf08a67d217b3af3246abb1d027095dab726f0687f01f43e8c03/psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/69/3b3d7bd583c6d3cbe5100802efa5beacaacc86e37b653fc708bf3d6853b8/psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142" }, +] + +[[package]] +name = "pwdlib" +version = "0.2.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/82/a0/9daed437a6226f632a25d98d65d60ba02bdafa920c90dcb6454c611ead6c/pwdlib-0.2.1.tar.gz", hash = "sha256:9a1d8a8fa09a2f7ebf208265e55d7d008103cbdc82b9e4902ffdd1ade91add5e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/01/f3/0dae5078a486f0fdf4d4a1121e103bc42694a9da9bea7b0f2c63f29cfbd3/pwdlib-0.2.1-py3-none-any.whl", hash = "sha256:1823dc6f22eae472b540e889ecf57fd424051d6a4023ec0bcf7f0de2d9d7ef8c" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" }, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/38/ea/5f572806ab4d4223d11551af814d243b0e3e02cc6913def4d1fe4a5ca41c/pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/d1/f86cc96d2aa80e3881140d16d12ef2b491223f90b28b9a911346c04ac359/pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/08/fbd2cd1e9fc735a0df0142fac41c114ad9602d1c004aea340169ae90973b/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/73/3ac217751decbf8d6cb9443cec9b9eb0130eeada6ae56403e11b486e277e/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/f5/5c26b265cdcff2661e2520d2d1e9db72d117ea00eb41e00a76efe68cb009/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/14/a9c3cee817ef2f8347c5ce0713e91867a0dceceefcb2973942855c917379/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/68/866ce83a51dd37e7c604ce0050ff6ad26de65a7799df89f4db87dd93d1d6/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/a8/36771f4404bb3e49bd6d4344da4dede0bf89cc1e01f3b723c47248a3761c/pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/9c/730a09b2694aa89360d20756369822d98dc2f31b717c21df33b64ffd1f50/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/8e/2dccd89602b5ec31d1c58138d02340ecb2ebb8c2cac3cc66b65ce3edb6ce/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/9c/126e4ac1bfad8a95a9837acdd0963695d69264179ba4ede8b8c40d741702/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/ba/91eea2047e681a6853c81c20aeca9dcdaa5402ccb7404a2097c2adf9d038/pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/c0/fcdf739bf60d836a38811476f6ecd50374880b01e3014318b6e809ddfd52/pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/c7/8b311d5adb0fe00a93ee9b4e92a02b0ec08510e9838885ef781ccbb20604/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/d6/4f58d32066a9e26530daaf9adc6664b01875ae0691570094968aaa7b8fcc/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/3f/53cc9c45d9229da427909c751f8ed2bf422414f7664ea4dde2d004f596ba/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/49/bf0783279ce674eb9903fb9ae43f6c614cb2f1c4951370258823f795368b/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/5b/0d998367687f986c7d8484a2c476d30f07bf5b8b1477649a6092bd4c540e/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/33/039287d410230ee125daee57373ac01940d3030d18dba1c29cd3089dc3ca/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/85/6d8b2646d99c062d7da2d0ab2faeb0d6ca9cca4c02da6076376042a20da3/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/d7/c37d208d5738f7b9ad8f22ae8a727d88ebf9c16c04ed2475122cc3f7224a/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/e0/bafa46476d328e4553b85ab9b2f7409e7aaef0ce4c937c894821c542d347/pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.10.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/53/fa/6b268a47839f8af46ffeb5bb6aee7bded44fbad54e6bf826c11f17aef91a/pydantic_extra_types-2.10.3.tar.gz", hash = "sha256:dcc0a7b90ac9ef1b58876c9b8fdede17fbdde15420de9d571a9fccde2ae175bb" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/38/0a/f6f8e5f79d188e2f3fa9ecfccfa72538b685985dd5c7c2886c67af70e685/pydantic_extra_types-2.10.3-py3-none-any.whl", hash = "sha256:e8b372752b49019cd8249cc192c62a820d8019f5382a8789d0f887338a59c0f3" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c" }, +] + +[[package]] +name = "pyjnius" +version = "1.6.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/e2/16d924821ccc31320f1f2bde7c2c59245a1a5033f69525dd5d2a1a7c953b/pyjnius-1.6.1.tar.gz", hash = "sha256:d2a7ece6ed79bf1d7f97a4f6d61302d9f1d7652182a3e4c8a69dbaef31396e60" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3a/61/3ecef9ae237dddf3ebb1f3c59a5061454baa28ad3e78c64b5d5f95f8689b/pyjnius-1.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff6d71b847620d6a7de33a8acaa53671003c235dc881413d3faa11bc8e075c40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/26/78503c8095c59f95fd1156e04894be2178ab0b106ef2b534aa7327695efa/pyjnius-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:061dd84a28695e750c809d2979fe3dabf9d4407b2240c2a06289755da3524bd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/b6/efcbbf7d955429540cb7a2b15ddd8f9e6bf8c95c1c67fc0ad1deb25853e3/pyjnius-1.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be215ef25bf2e5484e00ba27e7a7256bbedee1ad0e3213d3cffb748b8abffe20" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/82/b438251543cb67dfbf24d2c973cedd68c85ba15c34dbee08ed6cfc41bf1a/pyjnius-1.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d790bf3a5d30aa25757b98bc841a7bcb87c752ad6bea85a439f1582f884fa01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/4b/bbace6a6e7ca7e0cff51aa53e89a2d70cfb97cb304bdd775af61467995c8/pyjnius-1.6.1-cp310-cp310-win32.whl", hash = "sha256:cc33abad31b7dd0035b12adc9e02674d1dd299adc6b2028bdc7b998684109158" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/05/ec4f92d9e4baaa446e3414be24616e6a9d6584721df05dac906e337d77aa/pyjnius-1.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:4021629d8d53e615b244666a92e733cbcfaa82955bee4887df42282c8a4aa46e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/61/b7de6f3d95e226383c3eee2f8a1c0ebdfac5ccfbf5ffbb5f012ab0e59a4d/pyjnius-1.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2fc1ff1dc5dc93ee0adcb0a3f9194d9a824aeacefb0ee738c6d121964c6585f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/f0/d581fb235053875cc575532336e8f013601d467e1ef99b28ed3cde24b63c/pyjnius-1.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4359f42d907f8b971a676f2d1406ce41b1ba3c810c5770d640037d4addc5176e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/07/e9e8a062ff3dbdd13a61a9ae82aab57ddc4ab5bd976268c8791a6638b26e/pyjnius-1.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88dcb79d0ebc80ab74c1e6d37a0ffc9139521ff12eff1d5b2219be199f65536a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/e5/cdfd7dc6e26aa858e9d01c9d2b11e9c6f524d7e0a334693eaecbcce37102/pyjnius-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a32df745d40fd80c0c6e6c12417fa86d435ac23c1236d030365cababcf9981d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/d5/565d57d4532e51981787c3cb30c681c70abd9dee669246cc5375e461b290/pyjnius-1.6.1-cp311-cp311-win32.whl", hash = "sha256:c55837b1eaef929c2b1721388bdb51290893766e11442ed611776701c9266f15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/7f/2d5d17008d801739a366effef1f9b8245a54b50adbbd804d70005aa7e5e1/pyjnius-1.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:97b8c8faf16a4f1ac82cc89ca59c65a43d6a796b92884540c5627db2e54a962a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/67/f39d53248fcc3a01d9958788185519042094c48e9392aa7746024b5eef6a/pyjnius-1.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8e77043544febb3a37e5498477755195d7538b47b63b1d62a9ee7060ff0d8697" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/5c/a2c61dfa54779ca16b2879f23a981fb68088f93e2c16ba245e41ff04c5d7/pyjnius-1.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9f3775bda5275773b3e27a682220ddb8a9c649ab097c85798a366e7d4b8b709b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/3d/345b4a475d28f6cfea3d6058ad8a2682b4eb39cfe52611193d300d3d4766/pyjnius-1.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5cb3fb92898d6f2a1eeff348b71e80c001ef8594682273d45ca7b629877efda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/67/33113063363569f69a79a302111470c7a00c62700987a5c723d125b867b7/pyjnius-1.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a90b68fb53c4f9a0d1919903deed3bf5def72c355e38c5c07f545e72001d1c4c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/3d/80f050f99e978fe1db917fb94e4b9a3f929cf9628416d7d7f5e62527fc60/pyjnius-1.6.1-cp312-cp312-win32.whl", hash = "sha256:7199809fd3114db4942e560ac000ed8ecc4cdf2e115ef551012cd3d0b1af3a0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/c7/a8911d31ae655d54ed80bac987349d4580a78c915caf098cd76ea2b67632/pyjnius-1.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:0e0da04b08c61c2b59cd1670d743c7867f31d6c6a45c8ca5f90657da88d48fd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/23/d31dc9e39f946c73f62131f8e5dc4b452cb84f16809f35ac66aa76c5330c/pyjnius-1.6.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:26893e4ef5a5d21393ceab61d03ad02d03cf8d4c3177c262d48853186b5512df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/dd/8c5355ae6da4705db533737163ba77a6b5615543dfdc17b2ddb04c2f99b0/pyjnius-1.6.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:133ddedf0a88dae1b11a0e4ad36336794e38f190bde7c73f7621cbd21ad22700" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/25/335c98fef9e7993a3ec22fd9b65dcfc53b21762e35b27b26360a51e063c4/pyjnius-1.6.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55820ca521795454afa1f855ecad28648f440dcf70a4f188adffedb35c50e920" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/93/a9cf98a5d44f916dcee034b9c4478a72faf81b97b2acdc2b6870028b4678/pyjnius-1.6.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d9d75c37de19b43a7b33b3f965bd5206c6d5b9999de85d40797a4e46e48cd717" }, +] + +[[package]] +name = "pyliquibase" +version = "2.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pyjnius" }, + { name = "tqdm" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e9/1a/522c802671285263ce68b5d7ab519279dcc37b11a1649cde390c36f24c6a/pyliquibase-2.4.0.tar.gz", hash = "sha256:bf9599e78095ad2a5879b735fcbd06261f508cd4a570ff1495f17950490decde" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/80/b1/0f77e19dd42c9e17b88c93acc5b632fead7a96e7b10f0909ad99b71bfe52/pyliquibase-2.4.0-py3-none-any.whl", hash = "sha256:e66ecff19e39479ced53241fc97a1cc4a8331b06caeb1de6e8051068b04ecd5d" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d" }, +] + +[[package]] +name = "python-jose" +version = "3.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/19/b2c86504116dc5f0635d29f802da858404d77d930a25633d2e86a64a35b3/python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/bd/2d/e94b2f7bab6773c70efc70a61d66e312e1febccd9e0db6b9e0adf58cbad1/python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/0f/9c55ac6c84c0336e22a26fa84ca6c51d58d7ac3a2d78b0dfa8748826c883/python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3d/47/444768600d9e0ebc82f8e347775d24aef8f6348cf00e9fa0e81910814e6d/python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563" }, +] + +[[package]] +name = "redis" +version = "5.0.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/9c/1d57b0f61402aabdd9c3b2882e3fddd86a269c1df2cfd2e77daa607ef047/redis-5.0.4.tar.gz", hash = "sha256:ec31f2ed9675cc54c21ba854cfe0462e6faf1d83c8ce5944709db8a4700b9c61" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/65/f2/540ad07910732733138beb192991c67c69e7f6ebf549ce1a3a77846cbae7/redis-5.0.4-py3-none-any.whl", hash = "sha256:7adc2835c7a9b5033b7ad8f8918d09b7344188228809c98df07af226d39dec91" }, +] + +[package.optional-dependencies] +hiredis = [ + { name = "hiredis" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.14.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2e/ea/13945d58d556a28dfb0f774ad5c8af759527390e59505a40d164bf8ce1ce/rich_toolkit-0.14.1.tar.gz", hash = "sha256:9248e2d087bfc01f3e4c5c8987e05f7fa744d00dd22fa2be3aa6e50255790b3f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/66/e8/61c5b12d1567fdba41a6775db12a090d88b8305424ee7c47259c70d33cb4/rich_toolkit-0.14.1-py3-none-any.whl", hash = "sha256:dc92c0117d752446d04fdc828dbca5873bcded213a091a5d3742a2beec2e6559" }, +] + +[[package]] +name = "rsa" +version = "4.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.30" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/36/d0/0137ebcf0dc230c2e82a621b3af755b8788a2a9dd6fd1b8cd6d5e7f6b00d/SQLAlchemy-2.0.30.tar.gz", hash = "sha256:2b1708916730f4830bc69d6f49d37f7698b5bd7530aca7f04f785f8849e95255" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/92/d0/aec1421ff832da60badef9cf01fdc795b2ea399c5d65e2b8c37d801d06ff/SQLAlchemy-2.0.30-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b48154678e76445c7ded1896715ce05319f74b1e73cf82d4f8b59b46e9c0ddc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/86/25faae6b5c9920a7954bf7c68a7ff8f3436e9f140ac7dfccc8fc213bce66/SQLAlchemy-2.0.30-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2753743c2afd061bb95a61a51bbb6a1a11ac1c44292fad898f10c9839a7f75b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/da/90e8d421836c2d265b7a72a7923348705d1e0124321bb2b3f2de307b91d0/SQLAlchemy-2.0.30-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7bfc726d167f425d4c16269a9a10fe8630ff6d14b683d588044dcef2d0f6be7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/83/2ed47c5b841496d4e106f8ed04316c6193ba8615ab70fe769a593237b20b/SQLAlchemy-2.0.30-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4f61ada6979223013d9ab83a3ed003ded6959eae37d0d685db2c147e9143797" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/3f/b3f1bc1f14ab65ab1d8b4030ba0787b529bfde654b2b2cf9eb7cdb26b28c/SQLAlchemy-2.0.30-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a365eda439b7a00732638f11072907c1bc8e351c7665e7e5da91b169af794af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/e4/3550fba0561560cb8fae78d3636813f7a2b83eb7b55c76703ac34143cd3c/SQLAlchemy-2.0.30-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bba002a9447b291548e8d66fd8c96a6a7ed4f2def0bb155f4f0a1309fd2735d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/bc/e6117ae5cc3577fb3ee487f6321802ec72dcdd81fca13baf0db907818ad3/SQLAlchemy-2.0.30-cp310-cp310-win32.whl", hash = "sha256:0138c5c16be3600923fa2169532205d18891b28afa817cb49b50e08f62198bb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/6b/18900a4df0d91397569f645105a4fb36f12033075622e3d131c456dc73f3/SQLAlchemy-2.0.30-cp310-cp310-win_amd64.whl", hash = "sha256:99650e9f4cf3ad0d409fed3eec4f071fadd032e9a5edc7270cd646a26446feeb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/ae/062f6ebd474aef81a199a16d2b1fb521d5fb0bc38a470181b0bcbfe3eb11/SQLAlchemy-2.0.30-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:955991a09f0992c68a499791a753523f50f71a6885531568404fa0f231832aa0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/51/3baab95d7eea9816c59c8e093201288ce27651704927e03ccfe156b30792/SQLAlchemy-2.0.30-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f69e4c756ee2686767eb80f94c0125c8b0a0b87ede03eacc5c8ae3b54b99dc46" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/e1/9177748d4482d04ee67242b8cf441e18f7031b2d7e893b0894297f9e91f7/SQLAlchemy-2.0.30-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c9db1ce00e59e8dd09d7bae852a9add716efdc070a3e2068377e6ff0d6fdaa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/21/87bcad723070f7cd5f9d45fb05557596aa1d23d19eef078b13edc9e31813/SQLAlchemy-2.0.30-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1429a4b0f709f19ff3b0cf13675b2b9bfa8a7e79990003207a011c0db880a13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/3a/5328fd0c2bcd5572b23b14e3ca78d0abc8ad126e70b282b9e6f9fb00af6b/SQLAlchemy-2.0.30-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:efedba7e13aa9a6c8407c48facfdfa108a5a4128e35f4c68f20c3407e4376aa9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/39/b8a8633fb6f64dc4d4eef08d5d8b303d349eb14517c7cb602e1f03dc71a8/SQLAlchemy-2.0.30-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:16863e2b132b761891d6c49f0a0f70030e0bcac4fd208117f6b7e053e68668d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/d5/c9661baf0ad062375049ad0081b3343c4bd0e95e98e58ea6763f0dbbfc41/SQLAlchemy-2.0.30-cp311-cp311-win32.whl", hash = "sha256:2ecabd9ccaa6e914e3dbb2aa46b76dede7eadc8cbf1b8083c94d936bcd5ffb49" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/9a/eec023807ae78e83342567303916b34a348d9d40703e7cef5dfb1e3635b6/SQLAlchemy-2.0.30-cp311-cp311-win_amd64.whl", hash = "sha256:0b3f4c438e37d22b83e640f825ef0f37b95db9aa2d68203f2c9549375d0b2260" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/7d/00282d131c31108ef6ae666888fbe7797f97f2b55d43c1d088cd3bab2e54/SQLAlchemy-2.0.30-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5a79d65395ac5e6b0c2890935bad892eabb911c4aa8e8015067ddb37eea3d56c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/ab/d37a4483768accbc9cd433cc5d6c45fb428840164b9df0328131011ce27c/SQLAlchemy-2.0.30-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a5baf9267b752390252889f0c802ea13b52dfee5e369527da229189b8bd592e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/92/db44ea3953e1f3b81a9c2a2852aa7542839da3300e50ee5615a67c3932b0/SQLAlchemy-2.0.30-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cb5a646930c5123f8461f6468901573f334c2c63c795b9af350063a736d0134" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/b8/3fd88455562518b6e8b97c4bc5784a819bd0a5c26be2a3409d3245626fac/SQLAlchemy-2.0.30-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:296230899df0b77dec4eb799bcea6fbe39a43707ce7bb166519c97b583cfcab3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/65/b85460a54d7e379ad92bb0fa816caf53d5cf45924b738c6b57791a03f639/SQLAlchemy-2.0.30-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c62d401223f468eb4da32627bffc0c78ed516b03bb8a34a58be54d618b74d472" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/5e/620fa87990aa04308523e2bfaf61ce20ddc0a1082c9f3e548d0d26ab0033/SQLAlchemy-2.0.30-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3b69e934f0f2b677ec111b4d83f92dc1a3210a779f69bf905273192cf4ed433e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/72/7c2166f6182bcf3b35228878ec323771df60774bf0b63019afe3a0fc97b4/SQLAlchemy-2.0.30-cp312-cp312-win32.whl", hash = "sha256:77d2edb1f54aff37e3318f611637171e8ec71472f1fdc7348b41dcb226f93d90" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/01/bff536f96ea323a7d80df128a7bc947e3c25a60383425bf491232112c30d/SQLAlchemy-2.0.30-cp312-cp312-win_amd64.whl", hash = "sha256:b6c7ec2b1f4969fc19b65b7059ed00497e25f54069407a8701091beb69e591a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/80/13fc9c003dffc169e03244e0ce23495ff54bbd77ba1245ef01c9a5c04a4c/SQLAlchemy-2.0.30-py3-none-any.whl", hash = "sha256:7108d569d3990c71e26a42f60474b4c02c8586c4681af5fd67e51a044fdea86a" }, +] + +[[package]] +name = "sqlalchemy-crud-plus" +version = "1.6.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/37/6a/99d1908c96ba13da4941e7fa6e254220a14332a2dce244fa423487ea7759/sqlalchemy_crud_plus-1.6.0.tar.gz", hash = "sha256:a09a56c4a9dd909800b4be5868a72b985b1cffd548aa734e3e2199c3395d2a55" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d0/e9/0996d7e9be20473f4a80a1f8a5d679bfcbc2e5a567b2f0ca488ccaa2b475/sqlalchemy_crud_plus-1.6.0-py3-none-any.whl", hash = "sha256:b2c77957716023f1ab4beac27588c448b9f9fba12657aa4013b1946d26fffd84" }, +] + +[[package]] +name = "starlette" +version = "0.37.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/61/b5/6bceb93ff20bd7ca36e6f7c540581abb18f53130fabb30ba526e26fd819b/starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fd/18/31fa32ed6c68ba66220204ef0be798c349d0a20c1901f9d4a794e08c76d8/starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2" }, +] + +[[package]] +name = "typer" +version = "0.15.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f" }, +] + +[[package]] +name = "tzdata" +version = "2024.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/74/5b/e025d02cb3b66b7b76093404392d4b44343c69101cc85f4d180dd5784717/tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252" }, +] + +[[package]] +name = "ujson" +version = "5.10.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/00/3110fd566786bfa542adb7932d62035e0c0ef662a8ff6544b6643b3d6fd7/ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7d/91/91678e49a9194f527e60115db84368c237ac7824992224fac47dcb23a5c6/ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/2f/1ed8c9b782fa4f44c26c1c4ec686d728a4865479da5712955daeef0b2e7b/ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/bf/a3a38b2912288143e8e613c6c4c3f798b5e4e98c542deabf94c60237235f/ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/6d/0df8f7a6f1944ba619d93025ce468c9252aa10799d7140e07014dfc1a16c/ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/ec/370741e5e30d5f7dc7f31a478d5bec7537ce6bfb7f85e72acefbe09aa2b2/ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/29/72b33a88f7fae3c398f9ba3e74dc2e5875989b25f1c1f75489c048a2cf4e/ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/5c/808fbf21470e7045d56a282cf5e85a0450eacdb347d871d4eb404270ee17/ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/6a/e1e8281408e6270d6ecf2375af14d9e2f41c402ab6b161ecfa87a9727777/ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/ca/e319acbe4863919ec62498bc1325309f5c14a3280318dca10fe1db3cb393/ujson-5.10.0-cp310-cp310-win32.whl", hash = "sha256:c18610b9ccd2874950faf474692deee4223a994251bc0a083c114671b64e6518" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/ec/dc96ca379de33f73b758d72e821ee4f129ccc32221f4eb3f089ff78d8370/ujson-5.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:924f7318c31874d6bb44d9ee1900167ca32aa9b69389b98ecbde34c1698a250f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/ec/3c551ecfe048bcb3948725251fb0214b5844a12aa60bee08d78315bb1c39/ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/9f/4731ef0671a0653e9f5ba18db7c4596d8ecbf80c7922dd5fe4150f1aea76/ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/2b/44d6b9c1688330bf011f9abfdb08911a9dc74f76926dde74e718d87600da/ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/45/f5f5667427c1ec3383478092a414063ddd0dfbebbcc533538fe37068a0a3/ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/21/a0c265cda4dd225ec1be595f844661732c13560ad06378760036fc622587/ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/36/8fde862094fd2342ccc427a6a8584fed294055fdee341661c78660f7aef3/ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/37/9208e40d53baa6da9b6a1c719e0670c3f474c8fc7cc2f1e939ec21c1bc93/ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/d5/2626c87c59802863d44d19e35ad16b7e658e4ac190b0dead17ff25460b4c/ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/ee/03662ce9b3f16855770f0d70f10f0978ba6210805aa310c4eebe66d36476/ujson-5.10.0-cp311-cp311-win32.whl", hash = "sha256:2987713a490ceb27edff77fb184ed09acdc565db700ee852823c3dc3cffe455f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/20/952dbed5895835ea0b82e81a7be4ebb83f93b079d4d1ead93fcddb3075af/ujson-5.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:f00ea7e00447918ee0eff2422c4add4c5752b1b60e88fcb3c067d4a21049a720" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/a6/fd3f8bbd80842267e2d06c3583279555e8354c5986c952385199d57a5b6c/ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/47/dd03fd2b5ae727e16d5d18919b383959c6d269c7b948a380fdd879518640/ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/23/079a4cc6fd7e2655a473ed9e776ddbb7144e27f04e8fc484a0fb45fe6f71/ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/81/668707e5f2177791869b624be4c06fb2473bf97ee33296b18d1cf3092af7/ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/50/056d518a386d80aaf4505ccf3cee1c40d312a46901ed494d5711dd939bc3/ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/d6/aeaf3e2d6fb1f4cfb6bf25f454d60490ed8146ddc0600fae44bfe7eb5a72/ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/d5/1f2a5d2699f447f7d990334ca96e90065ea7f99b142ce96e85f26d7e78e2/ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/2c/6990f4ccb41ed93744aaaa3786394bca0875503f97690622f3cafc0adfde/ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/f5/a2368463dbb09fbdbf6a696062d0c0f62e4ae6fa65f38f829611da2e8fdd/ujson-5.10.0-cp312-cp312-win32.whl", hash = "sha256:6dea1c8b4fc921bf78a8ff00bbd2bfe166345f5536c510671bccececb187c80e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/2d/691f741ffd72b6c84438a93749ac57bf1a3f217ac4b0ea4fd0e96119e118/ujson-5.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:38665e7d8290188b1e0d57d584eb8110951a9591363316dd41cf8686ab1d0abc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/69/b3e3f924bb0e8820bb46671979770c5be6a7d51c77a66324cdb09f1acddb/ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287" }, + { url = "https://mirrors.aliyun.com/pypi/packages/32/8a/9b748eb543c6cabc54ebeaa1f28035b1bd09c0800235b08e85990734c41e/ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/50/4b53ea234413b710a18b305f465b328e306ba9592e13a791a6a6b378869b/ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/9d/8061934f960cdb6dd55f0b3ceeff207fcc48c64f58b43403777ad5623d9e/ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/be/7bfa84b28519ddbb67efc8410765ca7da55e6b93aba84d97764cd5794dbc/ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/eb/85d465abafb2c69d9699cfa5520e6e96561db787d36c677370e066c7e2e7/ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/76/2a63409fc05d34dd7d929357b7a45e3a2c96f22b4225cd74becd2ba6c4cb/ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/ed/582c4daba0f3e1688d923b5cb914ada1f9defa702df38a1916c899f7c4d1/ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/0c/9837fece153051e19c7bade9f88f9b409e026b9525927824cdf16293b43b/ujson-5.10.0-cp313-cp313-win32.whl", hash = "sha256:4c4fc16f11ac1612f05b6f5781b384716719547e142cfd67b65d035bd85af165" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/72/6cb6728e2738c05bbe9bd522d6fc79f86b9a28402f38663e85a28fddd4a0/ujson-5.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4573fd1695932d4f619928fd09d5d03d917274381649ade4328091ceca175539" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/53/e5f5e733fc3525e65f36f533b0dbece5e5e2730b760e9beacf7e3d9d8b26/ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/1f/f7bc02a54ea7b47f3dc2d125a106408f18b0f47b14fc737f0913483ae82b/ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/3a/d3921b6f29bc744d8d6c56db5f8bbcbe55115fd0f2b79c3c43ff292cc7c9/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/04/f4e3883204b786717038064afd537389ba7d31a72b437c1372297cb651ea/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/cd/9c6547169eb01a22b04cbb638804ccaeb3c2ec2afc12303464e0f9b2ee5a/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/bf/ecd14d3cf6127f8a990b01f0ad20e257f5619a555f47d707c57d39934894/ujson-5.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:baed37ea46d756aca2955e99525cc02d9181de67f25515c468856c38d52b5f3b" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553" }, +] + +[[package]] +name = "virtualenv" +version = "20.30.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/38/e0/633e369b91bbc664df47dcb5454b6c7cf441e8f5b9d0c250ce9f0546401e/virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4c/ed/3cfeb48175f0671ec430ede81f628f9fb2b1084c9064ca67ebe8c0ed6a05/virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6" }, +] + +[[package]] +name = "wait-for-it" +version = "2.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/11/b9/494d24f3572f40d667df174cfeef75a245e7314425b7c438717268b5350c/wait_for_it-2.3.0.tar.gz", hash = "sha256:bc6eaeb0912cf4d59c824067f36c73739bb7a6182912ec8984f2fb3447ef68c0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c4/93/fb9935c3bd263036785d30bdc84960c31bc2e925ec729a4db107231f5a4f/wait_for_it-2.3.0-py3-none-any.whl", hash = "sha256:22ddbf08d6fe1b5f59b3807d539a932df7cab3de2823cb8b63591f81fa666c38" }, +] + +[[package]] +name = "watchfiles" +version = "1.0.5" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/af/4d/d02e6ea147bb7fff5fd109c694a95109612f419abed46548a930e7f7afa3/watchfiles-1.0.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/31/9ee50e29129d53a9a92ccf1d3992751dc56fc3c8f6ee721be1c7b9c81763/watchfiles-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/8c/759176c97195306f028024f878e7f1c776bda66ccc5c68fa51e699cf8f1d/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/1a/5e977250c795ee79a0229e3b7f5e3a1b664e4e450756a22da84d2f4979fe/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/17/884cf039333605c1d6e296cf5be35fad0836953c3dfd2adb71b72f9dbcd0/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/e0/bcb6e64b45837056c0a40f3a2db3ef51c2ced19fda38484fa7508e00632c/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/e9/f67e9199f3bb35c1837447ecf07e9830ec00ff5d35a61e08c2cd67217949/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/ed/a6cf815f215632f5c8065e9c41fe872025ffea35aa1f80499f86eae922db/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/4c/e14978599b80cde8486ab5a77a821e8a982ae8e2fcb22af7b0886a033ec8/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/1a/9263e34c3458f7614b657f974f4ee61fd72f58adce8b436e16450e054efd/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/1f/1803a18bd6ab04a0766386a19bcfe64641381a04939efdaa95f0e3b0eb58/watchfiles-1.0.5-cp310-cp310-win32.whl", hash = "sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/3b/29a89de074a7d6e8b4dc67c26e03d73313e4ecf0d6e97e942a65fa7c195e/watchfiles-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/f4/41b591f59021786ef517e1cdc3b510383551846703e03f204827854a96f8/watchfiles-1.0.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/06/93789c135be4d6d0e4f63e96eea56dc54050b243eacc28439a26482b5235/watchfiles-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/db/1cd89bd83728ca37054512d4d35ab69b5f12b8aa2ac9be3b0276b3bf06cc/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/90/d8a4d44ffe960517e487c9c04f77b06b8abf05eb680bed71c82b5f2cad62/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/da/267a1546f26465dead1719caaba3ce660657f83c9d9c052ba98fb8856e13/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/31/33850dfd5c6efb6f27d2465cc4c6b27c5a6f5ed53c6fa63b7263cf5f60f6/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/84/b7d7b67856efb183a421f1416b44ca975cb2ea6c4544827955dfb01f7dc2/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/87/6dc5ec6882a2254cfdd8b0718b684504e737273903b65d7338efaba08b52/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/6c/3786c50213451a0ad15170d091570d4a6554976cf0df19878002fc96075a/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/b3/1427425ade4e359a0deacce01a47a26024b2ccdb53098f9d64d497f6684c/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/ba/f60e053b0b5b8145d682672024aa91370a29c5c921a88977eb565de34086/watchfiles-1.0.5-cp311-cp311-win32.whl", hash = "sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/ed/7603c4e164225c12c0d4e8700b64bb00e01a6c4eeea372292a3856be33a4/watchfiles-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/c2/99bb7c96b4450e36877fde33690ded286ff555b5a5c1d925855d556968a1/watchfiles-1.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/03/81f9fcc3963b3fc415cd4b0b2b39ee8cc136c42fb10a36acf38745e9d283/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/97/8c4213a852feb64807ec1d380f42d4fc8bfaef896bdbd94318f8fd7f3e4e/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/12/d4464d19860cb9672efa45eec1b08f8472c478ed67dcd30647c51ada7aef/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/fb/b07bcdf1034d8edeaef4c22f3e9e3157d37c5071b5f9492ffdfa4ad4bed7/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" }, +]