diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9329149 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +data/ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +src/backend/app/auto_db_deployment.egg-info + +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ + +src/frontend/node_modules +dist +dist-ssr +*.local +.VSCodeCounter + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.kiro/* + +# Virtual Environment +src/backend/venv/ + +# Vite build cache +src/frontend/.vite/ + +src/backend/app/static/ +node_modules +src/backend/todo.md +mcp/ \ No newline at end of file diff --git a/.vite/deps/_metadata.json b/.vite/deps/_metadata.json new file mode 100644 index 0000000..ced6ab9 --- /dev/null +++ b/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "e411725c", + "configHash": "2db6abac", + "lockfileHash": "e3b0c442", + "browserHash": "12a069ff", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/.vite/deps/package.json b/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/README.md b/README.md index 2707849..cae88b7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,80 @@ # 202326010 +本项目包含 **前端 (React + Vite)** 和 **后端 (FastAPI + Celery + PostgreSQL + MySQL + Redis)**,推荐使用 Docker 进行本地开发和运行。 + +当前目录结构(简化版): + +- `src/frontend`:前端 React + Vite 工程 +- `src/backend`:后端 FastAPI 工程 +- `docker-compose.yml`:一键启动数据库、后端、前端的 Docker 编排文件 + +## 如何在 Docker 中进行前端开发 + +> 适合对 Node.js / Vite 不熟悉的同学,全部命令只需要在项目根目录执行。 + +### 1. 先安装好开发环境 + +- **必须安装**: + - Docker Desktop(Windows 建议开启 WSL2 后端) + - `git`(用于拉取项目,可选) + +安装完成后,确保终端可以执行: + +```bash +docker --version +docker compose version +``` + +### 2. (推荐)只启动前端开发环境 + +如果你暂时只想改前端页面,不需要数据库和后端: + +```bash +cd D:\Desktop\202326010 # 进入项目根目录 +docker compose up frontend +``` + +启动成功后: + +- 在浏览器访问 `http://localhost:3000` +- 修改本地 `src/frontend` 目录下的代码,页面会自动热更新(HMR) + +> 停止开发:在终端中按 `Ctrl + C` 即可退出前端容器。 + +### 3. 启动「前端 + 后端 + 数据库」整体环境 + +如果需要调试前后端联动: + +```bash +cd D:\Desktop\202326010 +docker compose up +``` + +- 前端:`http://localhost:3000` +- 后端 API:`http://localhost:8000` + +你也可以只启动部分服务,例如: + +```bash +docker compose up backend postgres-meta mysql-user redis_client +``` + +### 4. 修改代码后的体验 + +前端和后端的代码目录都挂载到了容器中: + +- 修改 `src/frontend` 下的文件 → 前端页面自动刷新 +- 修改 `src/backend` 下的文件 → FastAPI + Celery 会自动重载(`--reload`) + +不需要在容器里重新安装依赖或重启容器,非常适合开发。 + +### 5. 常见问题 + +- **端口被占用**: + - 如果 `3000` 或 `8000` 已被其他程序占用,可以修改 `docker-compose.yml` 中对应的端口映射(冒号左边的数字)。 +- **前端打不开 / 白屏**: + - 确认终端中前端容器日志没有明显报错; + - 如果你本地也安装了 Node,不要同时在本机直接运行 `npm run dev` 和 Docker 里的前端服务,否则容易端口冲突。 + +> 如果你对终端命令不熟,只要记住:**进入项目目录,执行 `docker compose up frontend`,浏览器打开 `http://localhost:3000` 即可开始前端开发。** + diff --git a/doc/process/meeting/README.md b/doc/process/meeting/README.md new file mode 100644 index 0000000..a6eaa3e --- /dev/null +++ b/doc/process/meeting/README.md @@ -0,0 +1,12 @@ +# 会议记录目录 + +这里存放项目的所有会议记录文件,按日期分类。 + +## 目录说明 +- 按年月建立子目录(如 2025-10/) +- 存放详细的会议记录、讨论材料等 +- 与 weekly/ 中的会议纪要互为补充 + +## 文件命名规范 +建议使用:YYYY-MM-DD-会议主题.md +示例:2025-10-20-需求讨论会议.md diff --git a/doc/process/weekly/week-04/group/meeting-minutes-03.md b/doc/process/weekly/week-04/group/meeting-minutes-03.md new file mode 100644 index 0000000..e5dcfb1 --- /dev/null +++ b/doc/process/weekly/week-04/group/meeting-minutes-03.md @@ -0,0 +1,102 @@ +# 小组会议纪要-第4周 + +## 会议记录概要 + +**团队名称:** 3班-葫芦娃救bug +**指导老师:** 李友焕 +**主 持 人:** 王利蓉 +**记录人员:** 王利蓉 +**会议主题:** 需求获取-任务安排 +**会议地点:** 软件大楼105 +**会议时间:** 2025-10-11 16:00-17:30 +**纪录时间:** 2025-10-11 22:00 +**参与人员:** 李果霖、李文韬、梁峻耀、伊木然、王利蓉 + +--- + +## 会议内容 + +### 1. 需求获取与项目理解 + +围绕《基于大语言模型多智能体的数据库自动设计与部署系统》项目,团队成员进行了深入的需求理解与背景探讨: + +(1)**项目背景理解:** 团队成员一致认同当前数据库设计与部署过程高度依赖DBA专业知识,存在门槛高、周期长、易出错的问题。利用大语言模型实现自动化设计具有重要现实意义。 + +(2)**项目目标明确:** 系统旨在实现从自然语言需求到数据库实例上线的全流程自动化,用户仅需提供业务描述和服务器凭证即可获得可用的数据库。 + +(3)**技术借鉴确认:** 确认将融合《SchemaAgent》的多智能体分工框架与《文本到SQL调查》中的模块化生命周期思想,构建专业智能体协作系统。 + +(4)**核心功能梳理:** 初步确认系统需具备自然语言需求理解、多智能体协同设计、自动化部署与连接、设计验证与测试四大核心功能。 + +### 2. 项目经理王利蓉安排下周计划 + +(1)**项目主要任务方面:** +- 需求分析文档完善:基于会议讨论内容,进一步完善需求说明书 +- 原型界面设计:使用墨刀工具进行系统原型界面开发 +- 技术调研:对可能用到的开发工具、LLM接口、部署工具进行初步调研 + +(2)**具体任务安排:** +- 会议记录:伊木然 +- 会议主持:王利蓉 +- 文档管理:李文韬 +- 周计划文档:李文韬 +- 进度管理:李果霖 +- 日常考勤:梁峻耀 +- 项目开发:李果霖、李文韬、梁峻耀、伊木然、王利蓉 +- 项目测试:李果霖、李文韬、梁峻耀、伊木然、王利蓉 + +(3)**例会时间:** 星期六上午10:00-10:30 +(4)**讨论时间:** 星期六上午10:30-11:00 +- **实验开发地点:** 个人安排,同伴协商 +- **实验开发时间:** 个人安排,同伴协商 + +### 3. 讨论确定原型设计工具 + +(1)**原型工具确认:** 经过组内讨论和工具对比,确认使用墨刀进行原型界面开发 + +(2)**开发工具待定:** 具体开发工具(如IDE、框架、LLM接入方式等)尚未确定,需进一步调研 + +### 4. 讨论过程 + +会议首先由主持人介绍项目背景,随后各成员分享对项目的理解与看法。经过充分讨论,团队对项目需求形成了统一认识,并确定了下一阶段的工作重点和工具选择。 + +--- + +## 问题总结 + +### 已解决问题: +1. 明确了项目需求背景和系统核心功能 +2. 确定了下一周的工作计划和任务分工 +3. 确定了原型界面开发工具为墨刀 +4. 加深了团队成员对项目愿景和技术路线的理解 + +### 待解决问题: +1. 具体开发工具和技术栈尚未确定 +2. 详细的开发分工需要进一步明确 +3. 需要深入学习多智能体框架和LLM接入技术 +4. 原型界面的具体设计内容和交互流程有待细化 + +--- + +## 小组协作情况总结 + +1. **协作情况:** 小组协作情况良好。成员积极参与讨论,对项目表现出浓厚兴趣,沟通氛围融洽。 + +## 一周纪律情况总结 + +1. **纪律情况:** 小组纪律良好。本次会议全员准时参加,讨论过程专注有序。 + +--- + +## 备注 + +1. 项目当前重点在于完善需求文档和开展原型设计 +2. 需要尽快确定开发技术栈,为后续开发工作做好准备 + +--- + +## 【注】 + +1. 本文档为"葫芦娃救bug"小组软件过程会议记录,记录人员必须在会议后一个工作日之内如实填写,并汇报给PM、Lead及相关人员 +2. 文档内容已经标上编号,记录人员如有增加或者减少编号的需要,在保证文档格式正确的前提下修改 +3. 本文档内容在填写完毕之后,在已有的文件名称后面加上"(会议记录-第几周)",如"meeting-minutes-04",如果一周有多次会议,命名为"meeting-minutes-04-01" \ No newline at end of file diff --git a/doc/process/weekly/week-04/group/meeting-minutes-04.md b/doc/process/weekly/week-04/group/meeting-minutes-04.md new file mode 100644 index 0000000..40f7515 --- /dev/null +++ b/doc/process/weekly/week-04/group/meeting-minutes-04.md @@ -0,0 +1,101 @@ +# 小组会议纪要-第4周 + +## 会议记录概要 + +**团队名称:** 3班-葫芦娃救bug + +**指导老师:** 李友焕 + +**主 持 人:** 项目经理 (PM) + +**记录人员:** 伊木然 + +**会议主题:** 原型评审与技术栈确认 + +**会议地点:** 天马二区一栋A01 + +**会议时间:** 2025-10-19 15:00-18:00 + +**纪录时间:** 2025-10-19 19:00 + +**参与人员:** 全体成员 + +## 会议内容 + +### 1. 原型评审与界面优化讨论 + +- **原型演示**: 小组成员演示了使用 Axure 制作的初步原型,包括用户注册/登录、数据库项目列表、新建数据库项目(包含项目名、数据库类型选择、需求描述文本/语音输入)、以及核心工作区(对话窗口、输入区、历史会话列表)等界面 +- **管理员界面**: 重点讨论了管理员相关功能界面设计,明确需要包括知识管理(名词解释、业务逻辑解释)、用户管理等功能 。 +- **界面优化**: 针对现有原型,提出了交互流程和视觉风格上的优化建议,特别是新建数据库的引导流程和工作区的信息布局。 + +### 2. 技术栈方案确认 + +- **整体框架**: 会议确认了前后端分离的架构。 + +- **前端**: 暂定使用 **React** 框架,搭配 **Ant Design** UI库和 **ECharts** 进行图表可视化。考虑使用 **Zustand** 进行状态管理和 **SSE** (Server-Sent Events) 实现与后端的实时通信 。 + +- **后端**: 暂定使用 **Python** 语言,目前考虑选用 **FastAPI** 框架,利用其异步特性和自动文档生成优势。 + +- **数据库**: + + - **系统元数据库**: 暂定选用 **PostgreSQL**,存储用户信息、项目信息、会话历史、AI Prompt 模板等 。核心表结构参照技术栈文档设计,包含 `users`, `projects`, `session` 等至少7张核心表 + + - **用户业务数据库**: 动态生成的数据库可选用 **MySQL**或 **PostgreSQL**,每个项目分配独立的 database 和数据库用户以实现隔离 。 + +- **AI 服务**: + + - **Schema 生成**: 调用已有的 **SchemaAgent** 模型 。 + - **Text-to-SQL**: 结合现有模型基础,并计划进行微调优化 。 + - **其他 AI 功能** (如摘要、可视化推荐): 初期可调用通用大模型 API,后期根据效果和成本考虑优化方案 。 + +### 3. 后续任务安排 + +**(1)项目主要任务方面:** + +- **原型细化**:根据评审意见,继续完善Axure原型,特别是管理员界面和报表区的具体设计。 +- **数据库详细设计**:设计系统元数据库的详细表结构,绘制ER图。 +- **技术预研**:各成员按照分工深入学习React、FastAPI等相关技术。 + +**(2)具体任务安排:** + +- **会议记录**:李果霖 +- **会议主持**:王利蓉 +- **文档管理**:李文韬 +- **周计划与周总结**:梁峻耀 +- **进度管理**:李果霖 +- **日常考勤**:伊木然 +- **原型完善**:王利蓉、伊木然 +- **数据库设计**:全体成员 +- **技术学习**:全体成员按分工进行 + +**(3)会议时间安排:** + +- **例会时间**:星期日晚上7:00-10:00 +- **技术讨论时间**:星期日晚上7:00-10:00 + +## 问题总结 + +### 已解决问题: + +1. 初步评审了系统原型,明确了主要交互流程和需要优化的方向,特别是管理员相关功能。 +2. 确认了项目的主要技术选型框架(前端React, 后端FastAPI, 数据库Postgres+MySQL)。 + +### 待解决问题: + +1. 原型细节尚需打磨,特别是数据可视化/报表区的具体呈现方式。 +2. 系统元数据库的详细表结构设计需要尽快完成。 +3. AI 模型(尤其是 Text-to-SQL 和 DML 操作)的集成方式和性能需要进一步预研和测试。 +4. SQL 沙箱的具体实现方案需要调研确定。 + +## 小组协作情况总结 + +1. **协作情况:** 小组协作情况良好。会议期间,小组成员积极提问,讨论热情高。 + +## 一周纪律情况总结 + +1. **纪律情况:** 小组纪律良好,全员按时参与了本次线下会议。 + +## 备注 + +1. 本周重心在于根据评审意见迭代原型,并同步进行技术栈相关知识的学习和预研。 +2. 下周需要完成详细的数据库表结构设计,并开始搭建前后端项目的基础框架。 \ No newline at end of file diff --git a/doc/process/weekly/week-04/group/weekly-plan-04.md b/doc/process/weekly/week-04/group/weekly-plan-04.md new file mode 100644 index 0000000..c0f6954 --- /dev/null +++ b/doc/process/weekly/week-04/group/weekly-plan-04.md @@ -0,0 +1,28 @@ +# 小组周计划-第4周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-13 +**结束时间:** 2025-10-19 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 执行人 | 情况说明 | +|------|----------|--------|----------| +| 1 | **需求细化与原型模块划分** | 全体成员 | 2025-10-13,结合需求文档与老师建议,明确各功能模块(如需求输入、多智能体设计流程、部署配置、测试反馈等)并划分原型界面责任范围 | +| 2 | **原型界面功能设计** | 全体成员 | 2025-10-14前,各模块负责人完成界面功能设计,明确界面数量、交互逻辑,形成文字或草图说明,并在群内共享讨论 | +| 3 | **墨刀原型界面实现** | 全体成员 | 2025-10-16前,使用墨刀完成初步原型界面开发,确保界面连贯、功能完整 | +| 4 | **原型内部评审与优化** | 全体成员 | 2025-10-17,内部评审原型,统一风格与交互逻辑,进行初步优化 | +| 5 | **与指导老师沟通原型与需求** | 全体成员 | 2025-10-18后,向李友焕老师展示原型,听取建议,进一步明确需求细节与可行性 | +| 6 | **论文学习与框架理解** | 全体成员 | 2025-10-13至2025-10-19,学习《SchemaAgent》与《Text-to-SQL调查》两篇论文,理解多智能体框架与任务分解思想,为系统架构打下基础 | +| 7 | **开发环境统一确认** | 前后端负责人 | 2025-10-19前,确定前后端、数据库、等技术栈与开发工具,形成开发环境文档 | +| 8 | **技术预研与学习** | 全体成员 | 2025-10-13至2025-10-19,根据分工进行LLM调用、SSH连接、DDL生成等技术预研与学习 | + +## 小结 + +1. **原型设计注重用户体验**:在原型设计中应充分考虑用户操作流程与界面友好性,确保系统易用性。 +2. **论文学习与项目结合**:学习论文时注意思考如何将多智能体协作、错误恢复等机制融入系统设计。 +3. **主动沟通与反馈**:鼓励成员在原型设计和技术学习中主动提出问题,及时在小组内或向老师、学长寻求支持。 +4. **项目管理与进度控制**:PM需统筹进度,确保原型设计与论文学习同步推进,为后续开发奠定基础。 + diff --git a/doc/process/weekly/week-04/group/weekly-summary-04.md b/doc/process/weekly/week-04/group/weekly-summary-04.md new file mode 100644 index 0000000..26a129e --- /dev/null +++ b/doc/process/weekly/week-04/group/weekly-summary-04.md @@ -0,0 +1,29 @@ +# 小组周总结-第4周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-13 +**结束时间:** 2025-10-19 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | **需求细化与原型模块划分** | 完成 | 2025-10-14,全体成员参与讨论,明确各功能模块并划分原型界面责任范围 | +| 2 | **原型界面功能设计** | 部分完成 | 2025-10-15前,各模块负责人完成主要界面功能设计,但仍有2个页面需完善,安排至下周完成 | +| 3 | **原型界面实现** | 完成(工具调整) | 2025-10-18前,使用Axure完成初步原型界面开发(原计划使用墨刀,实际采用Axure) | +| 4 | **原型内部评审与优化** | 部分完成 | 2025-10-17,内部评审原型,统一风格与交互逻辑,进行初步优化,还需要和老师进一步沟通细节 | +| 5 | **与指导老师沟通原型与需求** | 部分完成 | 2025-10-18后,向李友焕老师展示了目前的原型,下次开会将沟通细节,进一步改进 | +| 6 | **论文学习与框架理解** | 部分完成 | 2025-10-13至2025-10-19,初步学习《SchemaAgent》与《Text-to-SQL调查》,理解多智能体框架与任务分解思想 | +| 7 | **开发环境与技术栈确定** | 完成 | 2025-10-19前,确定采用React+Ant Design前端框架与FastAPI后端框架,形成完整技术栈方案 | +| 8 | **技术预研与分工确定** | 进行中 | 2025-10-13至2025-10-19,根据分工进行技术预研,前后端分工方案已明确 | + +## 小结 + +1. **原型设计基本完成**:本周使用Axure完成主要原型界面设计,界面逻辑基本清晰,但仍有2个页面需要在下周完善。 +2. **工具调整适应灵活**:根据实际情况从墨刀调整为Axure,团队展现了良好的适应能力。 +3. **技术栈明确建立**:已确定采用React+Ant Design前端与FastAPI后端的技术架构,为后续开发奠定坚实基础。 +4. **与老师沟通持续推进**:通过向指导老师展示原型,明确了改进方向,后续将继续深入沟通细节。 +5. **技术预研有序进行**:团队成员根据分工开始React、FastAPI等相关技术的学习和预研。 +6. **希望获得的帮助**:建议组织一次关于"多智能体系统架构"或"React+FastAPI全栈开发实践"的专题讲座,以帮助团队更好地掌握技术栈。 diff --git a/doc/process/weekly/week-04/members/liangjunyao-weekly-plan-04.md b/doc/process/weekly/week-04/members/liangjunyao-weekly-plan-04.md new file mode 100644 index 0000000..2a42b5d --- /dev/null +++ b/doc/process/weekly/week-04/members/liangjunyao-weekly-plan-04.md @@ -0,0 +1,27 @@ +# 个人周计划-第4周 + +## 姓名与起止时间 + +**姓 名**:梁峻耀 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-13 + +**结束时间**:2025-10-19 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ------------------------ | ------ | ------------------------------------------------------------ | +| 1 | 研读论文 | 个人 | 2025-10-13至2025-10-14,在集体讨论“原型界面开发”前通读完《A Survey of Text-to-SQL in the Era of LLMs:Where are we, and where are we going?》和《SchemaAgent: A Multi-Agents Framework for Generating Relational Database Schema》。 | +| 2 | 学习原型设计的相关知识 | 个人 | 周内学习《Axure入门到精通-产品原型设计》,了解原型设计开发的相关知识。 | +| 3 | 原型界面开发 | 组员 | 按周计划安排,2025-10-14至2025-10-16,线上协作边学边探索完成原型界面开发。 | +| 4 | 确认小组分工 | 组员 | 按周计划安排,在基本构建完原型界面的框架并且和老师确认完项目需求后,确认小组内的分工。 | +| 5 | 与指导老师沟通原型与需求 | 组员 | 按周计划安排,2025-10-18,向指导老师展示并确认原型和整理出的基本需求。 | + +## 小结 + +1. **学习需求:**了解原型设计的相关知识,熟悉项目的流程。 +2. **知识储备:**实践一次完整项目的基本流程,学以致用。 +3. **文档撰写:**熟悉过程文档的编写,方便后续开发和维护。 diff --git a/doc/process/weekly/week-04/members/liangjunyao-weekly-summary-04.md b/doc/process/weekly/week-04/members/liangjunyao-weekly-summary-04.md new file mode 100644 index 0000000..1c87aa5 --- /dev/null +++ b/doc/process/weekly/week-04/members/liangjunyao-weekly-summary-04.md @@ -0,0 +1,27 @@ +# 个人周总结-第4周 + +## 姓名与起止时间 + +**姓 名**:梁峻耀 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-13 + +**结束时间**:2025-10-19 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | ------------------------ | -------- | ------------------------------------------------------------ | +| 1 | 研读论文 | 完成 | 2025-10-14通读完《A Survey of Text-to-SQL in the Era of LLMs:Where are we, and where are we going?》和《SchemaAgent: A Multi-Agents Framework for Generating Relational Database Schema》后,在随后的交流会议中提出是否要将SchemaAgent的逻辑设计推到物理设计,讨论结果是暂时不需要。 | +| 2 | 学习原型设计的相关知识 | 完成 | 周内看完了《Axure入门到精通-产品原型设计》,边看边实践,学习了Axure软件的用法。 | +| 3 | 原型界面开发 | 完成 | 2025-10-14至2025-10-16,线上协作边学边探索制作原型界面,完成了登录/注册页,数据库选择页,创建数据库页和工作区页的设计和交互逻辑设置。 | +| 4 | 确认小组分工 | 完成 | 在基本构建完原型界面的框架并且和老师确认完项目需求后,在2025-10-19晚上的小组会议确认小组内的分工。 | +| 5 | 与指导老师沟通原型与需求 | 完成 | 2025-10-14,向指导老师展示整理出的基本需求,帮助我们整理出了要使用的大部分技术栈,并指导了我们原型界面的设计。 | + +## 小结 + +1. **学习需求:**了解原型设计的相关知识,学习了Axure组件的交互功能,熟悉项目的流程。 +2. **原型制作:**制作了部分原型界面,但是界面过于简陋,后续可导入模板进行美化 +3. **后端探索:**在github上下载了一些热门的开源项目学习项目结构和后端设计 diff --git a/doc/process/weekly/week-04/members/liguolin-weekly-plan-04.md b/doc/process/weekly/week-04/members/liguolin-weekly-plan-04.md new file mode 100644 index 0000000..95e8658 --- /dev/null +++ b/doc/process/weekly/week-04/members/liguolin-weekly-plan-04.md @@ -0,0 +1,29 @@ +# 个人周计划-第4周 + +## 姓名和起止时间 + +**姓  名:** 李果霖 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-13 +**结束时间:** 2025-10-19 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ---------------------- | -------- | ------------------------------------------------------------ | +| 1 | **参与需求细化讨论** | 全体成员 | 2025-10-13,参加团队需求讨论会,明确个人负责的原型模块范围和设计要求。 | +| 2 | **核心论文研读** | 个人 | 2025-10-14前,通读《SchemaAgent》与《A Survey of Text-to-SQL》等论文,理解项目理论背景,为原型的信息架构和功能流程设计提供思路。 | +| 3 | **原型设计工具学习** | 个人 | 2025-10-15前,重点学习Axure 或墨刀 的使用方法,掌握组件、交互和页面流程设计等核心功能,为参与原型开发做准备。 | +| 4 | **原型界面设计与实现** | 组员 | 2025-10-14至2025-10-16,根据分工,与组员线上协作,使用Axure或墨刀完成个人负责模块的界面设计和交互实现。 | +| 5 | **参与原型内部评审** | 全体成员 | 2025-10-17,参加小组内部原型评审会,演示个人负责部分,并根据团队反馈记录优化点。 | +| 6 | **与指导老师沟通** | 全体成员 | 2025-10-18,随团队向指导老师展示原型,听取并记录老师对原型界面和需求的修改建议。 | +| 7 | **整理个人笔记与周报** | 个人 | 2025-10-19,整理本周的论文学习笔记和会议纪要,完成个人周报小结。 | + + + +## 小结 + +1. **掌握设计工具:** 本周首要任务是通过学习与实践,快速上手Axure或墨刀等原型工具,目标是能独立完成中等复杂度的界面交互设计。 +2. **设计驱动思考:** 尝试将在论文中学习到的多智能体、任务分解等概念,体现在原型界面的流程设计与交互逻辑中,做到设计有理有据。 +3. **融入团队协作:** 积极参与原型开发的各个环节,包括前期的需求讨论、中期的分工协作和后期的评审优化,确保个人设计产出与团队整体风格和进度保持一致。 +4. **提升文档能力:** 学习并实践如何撰写设计过程中的相关文档,为后续开发和项目交接打下良好基础。 diff --git a/doc/process/weekly/week-04/members/liguolin-weekly-summary-04.md b/doc/process/weekly/week-04/members/liguolin-weekly-summary-04.md new file mode 100644 index 0000000..b26d152 --- /dev/null +++ b/doc/process/weekly/week-04/members/liguolin-weekly-summary-04.md @@ -0,0 +1,34 @@ +# 个人周总结-第4周 + +## 姓名和起止时间 + +**姓  名:** 李果霖 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-13 +**结束时间:** 2025-10-19 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | ---------------------- | -------- | ------------------------------------------------------------ | +| 1 | **核心论文研读** | 完成 | 2025-10-16通读《SchemaAgent》与《A Survey of Text-to-SQL》等论文,理解项目理论背景 | +| 2 | **原型设计工具学习** | 完成 | 2025-10-14确定使用Axure开发原型,掌握组件、交互和页面流程设计等核心功能,完成基本操作学习 | +| 3 | **原型界面设计与实现** | 初步完成 | 2025-10-17继续学习Axure的使用方法、初步完成界面设计,下一步将继续完善原型界面,同时针对界面进行美化 | +| 4 | **参与原型内部评审** | 完成 | 2025-10-19参与小组会议评审,确认原型需要补充管理员系列页面以及完善知识管理页面 | +| 5 | **与指导老师沟通原型** | 未完成 | 推迟到2025-10-24,等待原型完善以及数据库设计完成后沟通相关细节 | +| 6 | **确认开发环境** | 完成 | 2025-10-19小组会议确认前端使用React,后端FastAPI,针对个人情况选择前端,进行相应课程学习 | +| 7 | **整理个人笔记** | 完成 | 2025-10-20整理本周的论文学习笔记和会议纪要,编写个人总结文档,制定小组下周计划。 | + + + +## 对团队工作的建议 + +1. **界面美化**:当前原型界面还不够现代化,需要进一步地美化,或者使用开源的美化组件库 +2. **模型训练**:进度需要加快,对于大模型微调相关知识还需要进一步了解 + +## 小结 + +1. **论文研读:**完成相关论文的研读,加深了AI在数据库领域应用的理解; +2. **原型制作:** 初步完成了原型的制作,后续根据需求进行调整和界面美化 +3. **技能学习:** 学习html,css,JavaScripts前端三件套,深入学习React框架。 + diff --git a/doc/process/weekly/week-04/members/liwentao-weekly-plan-04.md b/doc/process/weekly/week-04/members/liwentao-weekly-plan-04.md new file mode 100644 index 0000000..59294e5 --- /dev/null +++ b/doc/process/weekly/week-04/members/liwentao-weekly-plan-04.md @@ -0,0 +1,31 @@ +# 个人周计划-第4周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-13 + +**结束时间**:2025-10-19 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ------------------------ | ------ | ------------------------------------------------------------ | +| 1 | 研读论文 | 个人 | 2025-10-13至2025-10-14,阅读《SchemaAgent》与《Text-to-SQL调查》 | +| 2 | 学习原型设计工具的使用 | 个人 | 2025-10-13-2025-10-14调研各个主流原型开发工具,确定工具后学习使用 | +| 3 | 原型界面开发 | 组员 | 2025-10-14至2025-10-16,线上协作边学边探索完成原型界面初步开发。 | +| 4 | 参与原型内部评审 | 组员 | 2025-10-17,优化原型界面和交互。 | +| 5 | 与指导老师沟通原型与需求 | 组员 | 2025-10-18,向指导老师展示并确认原型,记录修改意见,细化需求,进一步完善需求文档。 | +| 6 | 确认开发环境 | 组员 | 2025-10-19前,参与小组讨论,确定前后端、数据库、等技术栈与开发工具。 | +| 7 | 一周总结 | 个人 | 2025-10-19整理一周文档,参与制定小组下周计划,并结合小组计划制定个人计划 | + + + +## 小结 + +1. **技术栈:**确认项目所需技术栈和开发工具。 +2. **学习需求:**学习项目相关论文,原型设计的相关知识,开始相关技术栈和开发工具使用的学习。 +3. **文档撰写:**熟悉过程文档的编写,方便后续开发和维护。 \ No newline at end of file diff --git a/doc/process/weekly/week-04/members/liwentao-weekly-summary-04.md b/doc/process/weekly/week-04/members/liwentao-weekly-summary-04.md new file mode 100644 index 0000000..6e4b523 --- /dev/null +++ b/doc/process/weekly/week-04/members/liwentao-weekly-summary-04.md @@ -0,0 +1,34 @@ + +# 个人周总结-第4周 + +## 姓名和起止时间 + +**姓  名:** 李文韬 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-13 +**结束时间:** 2025-10-19 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | ------------------------ | -------- | ------------------------------------------------------------ | +| 1 | 研读论文 | 完成 | 2025-10-14《SchemaAgent》与《Text-to-SQL调查》论文研读完成 | +| 2 | 学习原型设计工具的使用 | 完成 | 2025-10-14确定使用Axure开发原型,完成基本操作学习 | +| 3 | 原型界面开发 | 初步完成 | 2025-10-18持续学习Axure的使用方法、界面设计初步完成,下一步借鉴其他成熟的界面原型完善设计 | +| 4 | 参与原型内部评审 | 完成 | 2025-10-19参与小组会议评审,确认原型需要补充管理员系列页面以及完善知识管理页面 | +| 5 | 与指导老师沟通原型与需求 | 未完成 | 推迟到2025-10-24,待原型完善以及数据库设计完成后沟通 | +| 6 | 确认开发环境 | 完成 | 2025-10-19小组会议确认前端使用react,后端FastAPI,本人负责前端,开始学习相关内容 | +| 7 | 一周总结 | 完成 | 2025-10-20编写个人总结文档,制定小组下周计划。 | + + + +## 对团队工作的建议 + +1. **加快进度:** 目前项目进度较慢,特别是AI方面进度,需要重点关注。 + +## 小结 + +1. **论文研读:**完成相关论文的研读,加深了AI在数据库领域应用的理解; +2. **原型制作:** 初步完成了原型的制作,后续需优化; +3. **技能学习:** 启动react框架学习。 + diff --git a/doc/process/weekly/week-04/members/wanglirong-weekly-plan-04.md b/doc/process/weekly/week-04/members/wanglirong-weekly-plan-04.md new file mode 100644 index 0000000..a2fc662 --- /dev/null +++ b/doc/process/weekly/week-04/members/wanglirong-weekly-plan-04.md @@ -0,0 +1,31 @@ +# 个人周计划-第4周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-13 +**结束时间:** 2025-10-19 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | **论文学习与框架理解** | 个人 | 2025-10-13至2025-10-19,学习《SchemaAgent》与《Text-to-SQL调查》两篇论文,理解多智能体框架与任务分解思想,为系统架构设计提供理论基础 | +| 2 | **墨刀原型工具学习** | 个人 | 2025-10-14前,系统学习墨刀的基本操作、组件使用和交互设计方法,掌握原型设计核心功能 | +| 3 | **Git版本控制熟悉** | 个人 | 2025-10-15前,熟悉Git仓库的基本操作,包括分支管理、代码提交、合并请求等,确保能够熟练参与团队协作开发 | +| 4 | **项目相关知识学习** | 个人 | 2025-10-16前,学习多智能体系统、LLM调用、SSH连接等相关技术知识,为后续开发任务做准备 | +| 5 | **参与需求细化讨论** | 全体成员 | 2025-10-13,参与团队需求细化会议,理解各功能模块划分和原型界面责任范围 | +| 6 | **原型界面设计与实现** | 组员 | 2025-10-14至2025-10-16,根据分工完成负责模块的原型界面设计,使用墨刀实现界面功能 | +| 7 | **参与原型内部评审** | 全体成员 | 2025-10-17,参与原型内部评审会议,提出优化建议,统一界面风格和交互逻辑 | +| 8 | **与指导老师沟通** | 全体成员 | 2025-10-18后,参与与李友焕老师的沟通会议,汇报学习成果,听取老师对原型和需求的建议 | + +## 小结 + +1. **工具技能提升:** 希望通过实际操作和练习,快速掌握墨刀和Git的使用技巧,为团队原型设计贡献力量; +2. **理论知识积累:** 通过论文学习,深入理解多智能体框架的核心思想,为系统架构设计提供理论支撑; +3. **团队协作融入:** 积极参与团队讨论和评审,及时反馈学习进展和遇到的问题,确保个人进度与团队计划同步; +4. **实践应用结合:** 在学习过程中注重理论与实践的结合,思考如何将学到的知识应用到实际项目开发中。 + +--- +**说明:** 本计划根据团队周计划和个人学习目标制定,将根据实际进展情况动态调整。 \ No newline at end of file diff --git a/doc/process/weekly/week-04/members/wanglirong-weekly-summary-04.md b/doc/process/weekly/week-04/members/wanglirong-weekly-summary-04.md new file mode 100644 index 0000000..dccbf67 --- /dev/null +++ b/doc/process/weekly/week-04/members/wanglirong-weekly-summary-04.md @@ -0,0 +1,35 @@ +# 个人周总结-第4周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-13 +**结束时间:** 2025-10-19 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | **论文学习与框架理解** | 部分完成 | 2025-10-13至2025-10-19,初步学习了《SchemaAgent》与《Text-to-SQL调查》两篇论文,对多智能体框架有了基本理解,但还需要进一步深入研读 | +| 2 | **原型工具学习与应用** | 完成(工具调整) | 2025-10-14前,系统学习了Axure原型工具(原计划学习墨刀,实际使用Axure),掌握了基本组件使用和交互设计方法 | +| 3 | **Git版本控制熟悉** | 完成 | 2025-10-15前,熟悉了Git仓库的基本操作,包括分支管理、代码提交等,能够参与团队协作开发 | +| 4 | **项目相关知识学习** | 进行中 | 2025-10-16前,学习了多智能体系统、LLM调用等相关技术知识,为后续开发做准备,但仍需继续深入学习 | +| 5 | **参与需求细化讨论** | 完成 | 2025-10-14,积极参与团队需求细化会议,理解了各功能模块划分和原型界面责任范围 | +| 6 | **原型界面设计与实现** | 部分完成 | 2025-10-14至2025-10-18,根据分工完成了负责模块的原型界面设计,使用Axure实现了主要界面功能,但仍有部分细节需要完善 | +| 7 | **参与原型内部评审** | 完成 | 2025-10-17,积极参与原型内部评审会议,提出了优化建议,参与了界面风格和交互逻辑的统一讨论 | +| 8 | **与指导老师沟通** | 进行中 | 2025-10-18, 向老师展示了界面原型, 进一步的细节沟通将在下周的会议进行 | + +## 对团队工作的建议 + +1. **技能共享:** 建议团队成员定期分享各自学习的技术知识和工具使用心得,提高整体学习效率; +2. **进度同步:** 建议加强每日进度同步,确保各成员工作进度协调一致; +3. **文档规范:** 建议建立统一的文档编写规范,便于后续维护和知识传承。 + +## 小结 + +1. **工具技能掌握:** 成功掌握了Axure原型工具的基本使用,能够独立完成界面原型设计; +2. **理论知识积累:** 通过论文学习对多智能体框架有了初步理解,为后续系统架构设计奠定了基础; +3. **团队协作互相帮助:** 积极参与各项团队活动,及时完成分配任务,与团队成员配合良好; +4. **实践能力提高:** 能够将学到的原型设计知识应用到实际项目中,完成了负责模块的界面设计; +5. **希望获得的帮助:** 希望组织关于"多智能体系统实践案例"的专题讲座,增加对Ai的了解。 \ No newline at end of file diff --git a/doc/process/weekly/week-04/members/yimuran-weekly-plan-04.md b/doc/process/weekly/week-04/members/yimuran-weekly-plan-04.md new file mode 100644 index 0000000..bc1ea61 --- /dev/null +++ b/doc/process/weekly/week-04/members/yimuran-weekly-plan-04.md @@ -0,0 +1,40 @@ +# 个人周计划-第4周 + +## 姓名与起止时间 + +**姓 名**:伊木然 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-13 + +**结束时间**:2025-10-19 + + + +## 本周核心目标 + +1. 完成指定论文的研读,为项目设计提供理论支持。 +2. 通过学习与实践,掌握原型设计工具的基本使用。 +3. 与团队成员协作,完成初步的原型界面开发任务。 +4. 积极参与团队讨论和师生沟通,明确后续任务和分工。 + + + +## 本周任务计划安排 + +| 日期 | 星期 | 计划内容 | 产出/目标 | +| ---------- | ---- | ------------------------------------------------------------ | ----------------------------------------------- | +| 2025-10-13 | 一 | 参与团队会议,讨论需求细化与模块划分。 精读论文《SchemaAgent...》和《A Survey of Text-to-SQL...》。 | 明确个人在原型中的负责范围。 完成论文阅读。 | +| 2025-10-14 | 二 | 完成论文的通读,并整理核心观点。 开始学习原型设计知识(如墨刀或Axure)。 参与团队原型功能设计讨论。 | 整理好的论文摘要。 输出个人负责模块的设计草图。 | +| 2025-10-15 | 三 | 与组员线上协作,使用原型工具进行界面实现。 将学习到的原型设计知识应用到实践中。 | 原型开发 | +| 2025-10-16 | 四 | 继续协作,完成所负责模块的原型界面开发。 确保个人负责部分能与其它模块流畅衔接。 | 完成个人负责的初版原型。 | +| 2025-10-17 | 五 | 参与小组内部原型评审会议。 - 根据团队反馈,记录并开始优化原型界面和交互。 | 评审会议纪要。 - 开始进行原型优化。 | +| 2025-10-18 | 六 | 参与和指导老师的沟通会议,展示原型并听取建议。 记录老师提出的需求修改点和关键建议。 | 导师会议纪要。 | +| 2025-10-19 | 日 | 整理本周所有文档(论文笔记、会议纪要)。 参与小组分工讨论,明确后续开发职责。 复盘本周工作,规划下周学习任务。 | 个人周报小结。 明确下周个人目标。 | + +## 小结 + +1. **理论与实践结合:** 将本周从论文中学习到的多智能体框架、Text-to-SQL等思想,有意识地应用到原型设计和功能讨论中,确保设计有据可依。 +2. **工具快速上手:** 重点是通过“边学边做”的方式快速掌握原型工具,目标是实现功能而非追求完美,高效地产出成果以供团队讨论。 +3. **有效沟通:** 在与老师和同学的交流中,清晰地表达自己的想法,并准确记录关键反馈,为项目需求的最终确认和后续分工打下基础。 \ No newline at end of file diff --git a/doc/process/weekly/week-04/members/yimuran-weekly-summary-04.md b/doc/process/weekly/week-04/members/yimuran-weekly-summary-04.md new file mode 100644 index 0000000..79b11fb --- /dev/null +++ b/doc/process/weekly/week-04/members/yimuran-weekly-summary-04.md @@ -0,0 +1,30 @@ +# 个人周总结-第4周 + +## 姓名和起止时间 + +**姓  名:** 伊木然 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-13 +**结束时间:** 2025-10-19 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | ---------------- | -------- | ------------------------------------------------------------ | +| 1 | 研读指定论文 | 完成 | 2025-10-14前,通读了《SchemaAgent》与《Text-to-SQL调查》两篇核心论文,对多智能体框架和Text-to-SQL思想有了初步理解。 | +| 2 | 学习原型设计工具 | 完成 | 根据团队讨论,将原计划的墨刀调整为使用Axure,并快速学习了Axure的基本操作,为原型开发做好准备。 | +| 3 | 原型界面开发 | 初步完成 | 2025-10-18前,与组员协作完成了负责模块的初步原型设计,基本实现了核心交互流程,但部分页面细节仍需优化。 | +| 4 | 参与原型内部评审 | 完成 | 2025-10-17参与了小组内部评审会议,根据成员反馈,对统一界面风格和优化交互逻辑提出了建议。 | +| 5 | 与指导老师沟通 | 未完成 | 推迟到2025-10-24,待原型完善以及数据库设计完成后沟通 | +| 6 | 确认开发环境 | 完成 | 2025-10-19小组会议确认前端使用react,后端FastAPI,本人暂定后端,开始学习相关内容 | +| 7 | 编写周总结 | 完成 | 2025-10-20,根据本周实际完成情况,编写个人总结并参与制定下周计划。 | + +## 对团队工作的建议 + +- **加强技术预研**:建议前端和后端同学可以开始并行搭建项目的基础框架,同时加强对核心AI模块调用方式的预研,确保后续开发能够顺利对接。 + +##小结 + +1.技能沉淀与工具转型: 本周顺利完成了从墨刀到 Axure 的工具迁移,并通过研读核心论文,对 Text-to-SQL 的技术实现有了更具体的认知,为后续开发打下了理论与工具双重基础。 +2.产出落地: 与团队协作完成了原型的初步构建和内部评审,虽然部分细节尚待打磨,但核心交互逻辑已基本确立,迈出了产品可视化的第一步。 +3.方向明确: 通过小组会议明确了后端开发的角色定位及 FastAPI 技术栈,下一步将从设计阶段正式过渡到技术预研与开发准备阶段。 \ No newline at end of file diff --git a/doc/process/weekly/week-05/group/meeting-minutes-05.md b/doc/process/weekly/week-05/group/meeting-minutes-05.md new file mode 100644 index 0000000..e0742a4 --- /dev/null +++ b/doc/process/weekly/week-05/group/meeting-minutes-05.md @@ -0,0 +1,120 @@ +# 小组会议纪要-第5周 + +## 会议记录概要 + +**团队名称:** 3班-葫芦娃救bug + +**指导老师:** 李友焕 + +**主 持 人:** 项目经理 (PM) + +**记录人员:** 李果霖 + +**会议主题:** 原型和数据库评审 + +**会议地点:** 天马二区一栋A01 + +**会议时间:** 2025-10-26 10:00-11:30 + +**纪录时间:** 2025-10-26 19:00 + +**参与人员:** 全体成员 + +## 会议内容 + +### 1. 前端原型演示与评审 + +前端开发人员详细演示了基于React和Ant Design组件库开发的前端原型,主要覆盖了以下模块: + +- **登录注册**:展示了登录、注册的基础页面布局和流程。 +- **用户工作区**: + - **项目管理**:包含项目列表、创建新数据库项目(支持在线上传分析)、进入项目等功能。 + - **对话界面**:核心的自然语言对话窗口,包含历史对话记录、输入区和AI响应区。演示了发送自然语言查询后,界面预留了展示SQL语句和查询结果的位置。 + - **知识管理**:实现了对专业知识条目的增、删、改、查功能。 + - **报表区**:目前为空白占位,计划用于数据可视化图表展示。 + - **个人设置**:支持用户修改密码。 +- **管理员工作区**: + - **用户管理**:管理员可以查看、删除、封禁/解封用户,并查看用户拥有的项目列表。 + - **管理员管理**:可以查看其他管理员列表。 + +**评审结论**: + +- 原型基本完成了核心功能界面的搭建,但部分交互逻辑为前端写定,并非真实数据交互。 +- 识别出一些待完善的细节,例如:管理员在修改用户状态时,应使用下拉选择框而非手动输入文本;个人设置中可以增加用户头像、用户名等信息的修改功能。 + +### 2. 数据库设计评审 + +团队成员结合ER图,对数据库的表结构设计进行了详细讲解和评审。最终确认了包含8张核心表的设计方案。 + +- **核心实体与表结构**: + - **`users` (用户表)**:存储用户信息,包含`user_name`、`password`等字段,并通过`is_admin`字段区分普通用户和管理员。 + - **`projects` (项目表)**:存储用户创建的数据库项目,每个项目与一个用户关联。 + - **`database_instances` (数据库实例表)**:存储用户业务数据库的连接信息,如主机、端口、用户名、密码等,与`projects`表为一对一关系。 + - **`sessions` (会话表)**:记录用户在某个项目下的单次对话,与`projects`表为一对多关系。 + - **`messages` (消息表)**:存储会话中的每一条消息,包含消息类型(用户输入/AI回复)、消息内容、生成的SQL语句等关键信息。 + - **`expert_knowledge` (知识表)**:用于存储与特定项目相关的专业名词、业务逻辑解释,以增强AI对特定领域的理解。 +- **查询与日志分离设计**:为了区分查询操作(SELECT)和修改操作(如INSERT, UPDATE, DELETE),设计了两张独立的日志与结果表: + - **`query_result` (查询结果表)**:专门用于存储`messages`表中SELECT查询返回的数据快照(以JSON格式存储)和数据摘要。这实现了查询结果的缓存,避免了重复查询。 + - **`operation_log` (操作日志表)**:记录所有对数据库进行修改的操作,详细记录了操作类型、执行的SQL语句、影响的表等信息,为后续可能的数据回溯提供依据。 +- **关键关系说明**: + - 一个`user`可以拥有多个`projects`。 + - 一个`project`可以包含多个`sessions`和多条`expert_knowledge`。 + - 一个`session`可以包含多条`messages`。 + - 用户发起的修改数据库的操作会记录在`operation_log`中,并与对应的`user`和`session`关联。 + +### 3. 技术学习与任务讨论 + +- **前后端技术**:重申了前端使用React和Ant Design,后端使用Python的FastAPI框架的技术选型。 +- **后端任务划分**:讨论了后端的初步任务分工,计划将后端开发分为三大模块,并鼓励后端人员在学习后进行认领。 +- **AI模型**:明确了AI模型调用是两阶段过程:第一阶段`Text-to-Schema`,第二阶段`Schema-to-DDL/SQL`。 +- **后续计划**:确认了下周(第六周)的核心任务是完善需求规格说明书,并同步推进前后端的技术学习和细化工作。 + +## 后续任务安排 + +**(1)项目主要任务方面:** + +- **需求规格文档的编写**:团队根据下载好的模板,进一步编写对应的需求文档 +- **技术学习**:前后端人员各自学习对应知识,争取在一周内学完基本内容,在实际开发中进一步学习。 +- **技术调研**:调研报表区的图表生成;AI微调的原理以及成本研究。 +- **原型完善**:从用户角度出发,继续完善React原型,特别是管理员界面和报表区的具体设计。 + +**(2)具体任务安排:** + +- **会议记录**:李文韬 +- **会议主持**:王利蓉 +- **文档管理**:伊木然 +- **周计划与周总结**:梁峻耀 +- **进度管理**:梁峻耀 +- **日常考勤**:王利蓉 +- **原型完善**:李果霖,李文韬 +- **数据库设计**:全体成员 +- **技术学习**:全体成员按分工进行 + +**(3)会议时间安排:** + +- **例会时间**:星期日晚上7:00-10:00 +- **技术讨论时间**:星期日晚上7:00-10:00 + +## 问题总结 + +### 已解决问题: + +1. 完成了第五周的核心任务,包括初步的前端原型开发和数据库设计,并进行了内部评审。 +2. 明确了AI模型调用的两阶段实现模式。 +3. 演示并确认了当前原型的主要功能模块和交互流程。 + +### 待解决问题: + +1. 原型仍有细节需要优化,如管理员界面的用户状态修改交互、个人信息设置功能需要增强等。 +2. 报表功能区的前端实现方案需要进行技术调研(如 ECharts, Recharts 等)。 +3. 后端开发人员需要尽快熟悉FastAPI及异步编程,并完成任务认领。 +4. 《需求规格说明书》尚未定稿,需要全体成员协同完成。 +5. AI模型的微调方案(如QLoRA)、算力平台和成本需要进一步调研确认。 + +## 小组协作情况总结 + +1. **协作情况:** 团队协作顺畅,会议期间成员能够围绕原型和技术设计进行有效沟通和讨论。 + +## 一周纪律情况总结 + +1. **纪律情况:** 小组成员均按时参与会议,并完成了各自在第五周计划中的任务。 \ No newline at end of file diff --git a/doc/process/weekly/week-05/group/weekly-plan-05.md b/doc/process/weekly/week-05/group/weekly-plan-05.md new file mode 100644 index 0000000..a81fff1 --- /dev/null +++ b/doc/process/weekly/week-05/group/weekly-plan-05.md @@ -0,0 +1,27 @@ +# 小组周计划-第5周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-20 +**结束时间:** 2025-10-26 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 执行人 | 情况说明 | +|------|----------|--------|----------| +| 1 | 完善界面原型 | 王利蓉,伊木然 | 界面补充管理员系列页面,完善名词解释页面 | +| 2 | 数据库设计 | 全体成员 | 设计库表结构,明确各个字段数据类型与约束,绘制er图 | +| 3 | 前端技术学习 | 前端开发人员 | 学习react框架,根据原型明确需要实现的交互逻辑 | +| 4 | 后端技术学习 | 后端开发人员 | 学习FastAPI框架与后端调用模型方法 | +| 5 | AI微调学习 | AI负责人员 | 调研并选择合适的算力租用平台,学习ai微调流程 | +| 6 | 需求评审与确认 | 全体成员 | 准备原型与数据库设计成果,向老师汇报,并根据反馈进一步明确需求。 | +| 7 | 撰写需求规格书 | 全体成员 | 结合会议意见,完成V1.0需求规格说明书的初稿 | + +## 小结 + +1. 本周的核心产出是完善后的界面原型和数据库设计。全体成员需为周中的需求评审(任务6)做好准备,确保设计成果能够清晰传达方案。 +1. 前后端及AI相关的技术学习(任务3、4、5)必须以目标为导向。学习React、FastAPI和AI微调流程时,要对照原型和需求,思考“如何用这项技术实现这个功能”。 +1. 与老师的沟通(任务6)是本周的关键节点。目标是获取对原型和数据库设计的确认,并在此基础上,迅速落地为V1.0需求规格书(任务7),为开发工作提供清晰依据。 +1. 本周涉及原型、数据库、技术学习和需求文档四条并行线。PM需把控进度;团队成员在各自任务中(尤其是技术学习)遇到难点时,应主动在组内或向老师、学长寻求支持,确保不拖延整体进度。 + diff --git a/doc/process/weekly/week-05/group/weekly-summary-05.md b/doc/process/weekly/week-05/group/weekly-summary-05.md new file mode 100644 index 0000000..24938d6 --- /dev/null +++ b/doc/process/weekly/week-05/group/weekly-summary-05.md @@ -0,0 +1,32 @@ +# 小组周总结-第5周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-10-20 + +**结束时间:** 2025-10-26 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | **完善界面原型** | 完成 | 王利蓉、伊木然按计划补充了管理员系列页面,并完善了名词解释页面。李果霖进一步美化界面并完善交互逻辑 | +| 2 | **数据库设计** | 完成 | 梁峻耀完成了库表结构初步设计,与李文韬,李果霖讨论明确了字段、数据类型与约束,并绘制了ER图。 | +| 3 | **前端技术学习** | 持续进行中 | 前端开发人员(李果霖,李文韬)完成html,css学习,js学习中,后续继续学习react框架 | +| 4 | **后端技术学习** | 持续进行中 | 后端开发人员(梁峻耀,王利蓉、伊木然)讨论确定技术学习路线:web框架用fastapi,数据库用PostgreSQL,缓存用redis,异步任务用celery,完成后端分工。 | +| 5 | **AI微调学习** | 持续进行中 | AI开发人员继续学习微调方法并收集数据集,后续调研算力平台(AutoDL、阿里云PAI、Lambda Labs)对比价格、显存、训练时长、QLoRA 支持情况。 | +| 6 | **需求评审与确认** | 完成 | 全体成员按计划准备了原型与数据库成果,向老师进行汇报,并获得反馈,需求进一步明确。 | +| 7 | **撰写需求规格书** | 持续进行中 | 周内完成需求规格内容的整理,后续需要将内容填充到SRS文档模板中,并补足缺漏和不足 | + +## 小结 + +1. **核心任务完成**:本周完成了两大关键任务:界面原型和数据库设计。原型方面,已补充管理员页面、名词解释页并优化了交互逻辑;数据库方面,已完成表结构、字段约束设计及ER图绘制。 + +2. **技术栈细化与学习推进**:本周技术学习持续进行中。后端明确了FastAPI + PostgreSQL + Redis + Celery的完整技术路线并完成分工。前端已完成HTML/CSS,正转向JS和React学习。AI方面同步学习微调方法并收集数据集。 + +3. **文档工作进行中**: 需求规格说明书的内容已整理完毕,下一步是将其填充至标准SRS文档模板并补足缺漏。 + + + diff --git a/doc/process/weekly/week-05/members/liangjunyao-weekly-plan-05.md b/doc/process/weekly/week-05/members/liangjunyao-weekly-plan-05.md new file mode 100644 index 0000000..408a850 --- /dev/null +++ b/doc/process/weekly/week-05/members/liangjunyao-weekly-plan-05.md @@ -0,0 +1,28 @@ +# 个人周计划-第5周 + +## 姓名与起止时间 + +**姓 名**:梁峻耀 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-20 + +**结束时间**:2025-10-26 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | -------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | 后端逻辑探索与设计 | 个人 | 2025-10-21,结合小组讨论出的技术栈(redis、消息队列等等),整理出后端需要和配套的技术。 | +| 2 | 后端技术学习 | 个人 | 2025-10-23,在后端技术明确并分工后,学习对应的后端技术。 | +| 3 | 后端技术分工讨论 | 后端开发人员 | 2025-10-22,结合整理出的的后端技术,开个小会进行后端分工。 | +| 4 | 数据库设计 | 后端开发人员 | 2025-10-24到2025-10-27,结合已经完成交互逻辑的原型界面和群内整理出来的信息交互流程文档,从需求出发绘制ER图,设计库表结构。 | +| 5 | 与指导老师沟通确认原型界面 | 全体组员 | 按周计划安排,2025-10-24,向指导老师展示并确认初步完成交互逻辑原型界面。 | +| 6 | 完成项目需求规格书 | 全体组员 | 按周计划安排,在结合会议意见后,组织完成项目需求规格书。 | + +## 小结 + +1. **学习需求:**捋清楚后端的整体逻辑,整理出需要用到的技术并按计划学习。 +2. **知识储备:**通过下载github上热门的FastAPI项目,学习他们的项目结构,可以作为参考。 +3. **文档撰写:**学习参考课本上的需求文档,在本项目的需求文档中实践。 diff --git a/doc/process/weekly/week-05/members/liangjunyao-weekly-summary-05.md b/doc/process/weekly/week-05/members/liangjunyao-weekly-summary-05.md new file mode 100644 index 0000000..a357b8d --- /dev/null +++ b/doc/process/weekly/week-05/members/liangjunyao-weekly-summary-05.md @@ -0,0 +1,29 @@ +# 个人周总结-第5周 + +## 姓名与起止时间 + +**姓 名**:梁峻耀 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**: 2025-10-20 + +**结束时间**: 2025-10-26 + +## 本周任务计划安排 + +| 序号 | 总结任务 | 是否完成 | 情况说明 | +| ---- | -------------------------- | -------- | ------------------------------------------------------------ | +| 1 | 后端逻辑探索与设计 | 完成 | 2025-10-21,结合小组讨论出的技术栈,整理出后端技术需求:web框架使用FastAPI,业务数据库使用PostgreSQL,缓存用Redis,异步任务用Celery。 | +| 2 | 后端技术学习 | 完成 | 2025-10-23,已熟悉python、python异步协程和FastAPI前端交互的部分内容。 | +| 3 | 后端技术分工讨论 | 完成 | 2025-10-22,结合整理出的的后端技术,已经将后端任务分成了三个角色,一是是用户与项目管理,负责后端的用户注册登录,创建项目,调用模型1,数据库自动部署,二是对话与消息模块,负责管理会话、上下文、用户专业知识,并将必要信息传给模型2,要解析sql并确认sql安全性,三是负责sql执行与结果处理,负责管理业务数据库,执行SQL,缓存查询结果,记录审计日志,需要学习redis和celery的基础知识 | +| 4 | 数据库设计 | 完成 | 2025-10-24到2025-10-27,结合已经完成交互逻辑的原型界面和群内整理出来的信息交互流程文档,成功完成初步的数据库表设计,并验证完成了ER图是制作,确保数据库设计贴合项目需求。 | +| 5 | 与指导老师沟通确认原型界面 | 完成 | 2025-10-24,在讨论群内与老师关于原型界面和AI调优进行了讨论 | +| 6 | 完成项目需求规格书 | 部分完成 | 周内只完成了需求规格内容的整理,目前还需要将内容填充到规范的项目需求规格文档中,并补足缺漏和不足。 | + +## 小结 + +1. **数据库库表设计:**结合项目需求和流程,完成了数据库库表的设计。 +2. **后端知识学习:**学习了python的异步协程,并在这个基础上部分理解了FastAPI的设计理念,通过下载了github上热门的FastAPI项目,学习了他们的项目结构。 +3. **需求文档撰写:**将准备好的内容填充到规范的项目需求规格文档中,学习规范的需求规格文档是如何撰写的。 + diff --git a/doc/process/weekly/week-05/members/liguolin-weekly-plan-05.md b/doc/process/weekly/week-05/members/liguolin-weekly-plan-05.md new file mode 100644 index 0000000..bcb2458 --- /dev/null +++ b/doc/process/weekly/week-05/members/liguolin-weekly-plan-05.md @@ -0,0 +1,32 @@ +# 个人周计划-第5周 + +## 姓名与起止时间 + +**姓 名**:李果霖 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-20 + +**结束时间**:2025-10-26 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ---------------------- | -------- | ------------------------------------------------------------ | +| 1 | **前端技术学习** | 个人 | 2025-10-20-2025-10-26在已有前端技术基础上,进一步深入了解css盒子模型;同时进一步学习React框架,掌握基本组件所需原理,并根据原型设计了解所需组件 | +| 2 | **数据库设计** | 组员 | 2025-10-20-2025-10-22与组员协作设计库表结构,明确各个字段数据类型与约束,明确需求后绘制er图 | +| 3 | **需求评审与确认** | 组员 | 2025-10-24准备原型与数据库设计成果,向老师汇报,并根据反馈进一步明确需求。 | +| 4 | **模型微调学习** | AI负责人 | 2025-10-20-2025-10-25调研大模型微调所需工具及知识,选定开源大模型进行微调 | +| 5 | **撰写需求规格书** | 组员 | 2025-10-26结合需求评审会议意见,与组员协作完成V1.0需求规格说明书的初稿 | +| 6 | **整理个人笔记与周报** | 个人 | 2025-10-26整理本周的论文学习笔记和会议纪要,完成个人周报小结。 | + + + +## 小结 + +1. **学习需求**:本周核心任务是关键技术的学习。前端方向重点掌握React框架的基础语法与组件化思想,结合CSS盒子模型深入理解页面布局机制;AI方向聚焦大模型微调技术预研,调研主流微调方法(如LoRA)、开源模型(如Qwen、Llama系列)及适配的算力平台,完成技术选型与实验环境规划。 +2. **文档撰写**:协同团队完成《V1.0需求规格说明书》初稿的撰写工作,整合原型设计、功能模块划分与用户需求分析成果,确保文档内容与评审反馈一致,为后续开发提供明确依据。 +3. **协作推进**:参与数据库表结构设计与需求评审会议,基于团队分工完成对应模块的字段定义与关联逻辑设计,并根据指导老师意见优化原型与需求细节,提升整体方案的可行性与完整性。 + + \ No newline at end of file diff --git a/doc/process/weekly/week-05/members/liguolin-weekly-summary-05.md b/doc/process/weekly/week-05/members/liguolin-weekly-summary-05.md new file mode 100644 index 0000000..c81fcd3 --- /dev/null +++ b/doc/process/weekly/week-05/members/liguolin-weekly-summary-05.md @@ -0,0 +1,34 @@ +# 个人周总结-第5周 + +## 姓名和起止时间 + +姓  名: 李果霖 + +团队名称: 3班-葫芦娃救bug + +开始时间: 2025-10-20 + +结束时间: 2025-10-26 + +## 本周任务完成情况 + +| **序号** | **总结内容** | **是否完成** | **情况说明** | +| -------- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **前端技术学习** | 完成 | 学习React框架及相关钩子函数,并掌握了Ant Design组件库的基本使用方法,为原型开发打下了基础。 | +| 2 | **前端原型开发与演示** | 完成 | 基于React和Ant Design,协作完成了项目核心界面的代码实现,包括用户和管理员工作区,并在周日的小组会议上成功演示。 | +| 3 | **数据库设计** | 完成 | 与团队成员共同完成了系统元数据库的详细设计,确认了8张核心表的结构与关系,并绘制了ER图。 | +| 4 | **参与需求评审** | 完成 | 在小组会议上展示了前端原型,参与数据库设计评审;听取了成员的反馈意见,明确了下一步的优化方向(如管理员界面的交互细节与图表技术调研)。 | +| 5 | **AI模型微调学习** | 进行中 | 对大模型微调的方案(如LoRA/QLoRA)和流程进行了初步调研,为第六周深入研究算力平台和成本打下基础。 | +| 6 | **撰写需求规格书** | 进行中 | 根据会议评审的结论,已开始着手需求规格说明书的初稿撰写工作,将在下周协同团队完成。 | +| 7 | **整理个人笔记与周报** | 完成 | 整理了本周的技术学习笔记和会议内容,编写了本份周总结。 | + +## 对团队工作的建议 + +1. **明确需求文档分工**:下周的核心任务之一是完成需求规格书,建议PM尽快下发模板并明确章节分工,确保团队能在周五前高效地完成定稿。 +2. **后端任务尽快认领**:后端任务已划分为三个模块,建议后端同学尽快熟悉FastAPI基础后,结合个人兴趣认领具体模块,以便尽早进入边学边做的开发阶段。 + +## 小结 + +1. **原型实现突破**:本周最大的进展是将静态原型转化为了基于React和Ant Design的动态前端代码。通过实际开发,不仅将前期设计落地,也实现了用户、管理员的核心操作界面,并在评审中验证了主要交互流程的合理性。 +2. **技术基础夯实**:本周学习了React的核心知识,并参与了数据库最终版的设计评审。这为后续的前端开发和理解前后端数据交互逻辑打下了坚实的基础。 +3. **明确后续方向**:通过小组评审,明确了原型需要优化的具体细节(如管理员用户状态修改、个人信息功能增强)。同时,下周将重点投入到报表功能的技术选型调研(如ECharts)和需求规格书的最终定稿工作中。 \ No newline at end of file diff --git a/doc/process/weekly/week-05/members/liwentao-weekly-plan-05.md b/doc/process/weekly/week-05/members/liwentao-weekly-plan-05.md new file mode 100644 index 0000000..3e9c505 --- /dev/null +++ b/doc/process/weekly/week-05/members/liwentao-weekly-plan-05.md @@ -0,0 +1,31 @@ +# 个人周计划-第5周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-20 + +**结束时间**:2025-10-26 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | -------------- | ---------- | ------------------------------------------------------------ | +| 1 | 前端技术学习 | 个人 | 2025-10-20至2025-10-26学习react框架,课程资源为《前端Web开发HTML5+CSS3+移动web视频教程》与《黑马程序员前端React18入门到实战视频教程》根据原型明确需要实现的交互逻辑 | +| 2 | 数据库设计 | 组员 | 2025-10-20至2025-10-22与组员协作设计库表结构,明确各个字段数据类型与约束,绘制er图 | +| 3 | 需求评审与确认 | 组员 | 2025-10-24准备原型与数据库设计成果,向老师汇报,并根据反馈进一步明确需求。 | +| 4 | 撰写需求规格书 | 组员 | 2025-10-26结合会议意见,与组员协作完成V1.0需求规格说明书的初稿 | +| 5 | AI微调学习 | AI负责人员 | 2025-10-20至2025-10-26调研并选择合适的算力租用平台,学习ai微调流程。 | +| | 一周总结 | 个人 | 2025-10-26整理一周文档,参与制定小组下周计划,并结合小组计划制定个人计划 | + + + +## 小结 + +1. **学习需求:**本周核心任务是关键技术的学习。前端需掌握React框架基础,AI方向需完成微调流程的预研并选定算力平台。 +2. **文档撰写:**本周预计输出V1.0版的需求规格说明书初稿 + + \ No newline at end of file diff --git a/doc/process/weekly/week-05/members/liwentao-weekly-summary-05.md b/doc/process/weekly/week-05/members/liwentao-weekly-summary-05.md new file mode 100644 index 0000000..ae66219 --- /dev/null +++ b/doc/process/weekly/week-05/members/liwentao-weekly-summary-05.md @@ -0,0 +1,37 @@ + +# 个人周总结-第5周 + +## 姓名和起止时间 + +**姓  名:** 李文韬 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-20 +**结束时间:** 2025-10-26 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | -------------- | ---------- | ------------------------------------------------------------ | +| 1 | 前端技术学习 | 持续进行中 | 2025-10-20至2025-10-26,按计划完成html,css学习,js学习中,后续继续学习react框架 | +| 2 | 数据库设计 | 完成 | 2025-10-20至2025-10-22,与组员协作完成了库表结构设计,明确了各个字段数据类型与约束,并绘制了ER图。 | +| 3 | 需求评审与确认 | 完成 | 2025-10-24,与组员共同准备了原型与数据库设计成果,向老师进行了汇报,并根据反馈进一步明确了需求。 | +| 4 | 撰写需求规格书 | 持续进行中 | 2025-10-20至2025-10-26,完成需求规格内容的整理,后续需要将内容填充到SRS文档模板中,并补足缺漏和不足 | +| 5 | 跟进AI微调学习 | 持续进行中 | 2025-10-20至2025-10-26,学习微调流程,微调所需数据集正在收集标注。 | +| 6 | 一周总结 | 完成 | 2025-10-26,整理了一周文档,参与制定了小组下周计划,并结合小组计划制定了个人计划。 | + + + +## 对团队工作的建议 + +1. 目前项目进度较慢,特别是AI方面进度,需要重点关注。 +1. 技术学习应尽快完成,下周开始代码开发 + +## 小结 + +“数据库设计”和“需求评审与确认”均已完成。通过与老师的评审,团队需求得到了进一步明确。 + +目前,有三项任务仍在持续进行中: + +1. **技术学习:** 前端技术和AI微调方法都在按计划学习。AI方面开始了数据集的准备工作。 +2. **文档撰写:** 需求规格书的内容整理已完成,但还需后续填充到标准模板中。 + diff --git a/doc/process/weekly/week-05/members/wanglirong-weekly-plan-05.md b/doc/process/weekly/week-05/members/wanglirong-weekly-plan-05.md new file mode 100644 index 0000000..a9b2fcb --- /dev/null +++ b/doc/process/weekly/week-05/members/wanglirong-weekly-plan-05.md @@ -0,0 +1,29 @@ +# 个人周计划-第5周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-20 +**结束时间:** 2025-10-26 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | **完善界面原型** | 伊木然 | 2025-10-22前,补充管理员系列页面,完善名词解释页面,确保界面设计完整性和用户体验 | +| 2 | **参与数据库设计讨论** | 全体成员 | 2025-10-23前,参与数据库设计讨论,理解表结构设计和字段约束,为后续开发做准备 | +| 3 | **FastAPI框架基础学习** | 个人 | 2025-10-25前,系统学习FastAPI基础知识,包括路由设置、请求处理、响应模型等核心概念 | +| 4 | **后端项目结构学习** | 个人 | 2025-10-24前,通过研究GitHub上优秀的FastAPI项目,学习标准的后端项目结构和代码组织方式 | +| 5 | **参与需求评审准备** | 全体成员 | 2025-10-24前,协助整理技术方案,为向老师汇报做好准备工作 | +| 6 | **撰写需求规格书技术部分** | 全体成员 | 2025-10-26前,参与需求规格说明书中技术架构和数据库设计相关内容的撰写 | +| 7 | **后端开发环境搭建** | 个人 | 2025-10-21前,完成FastAPI开发环境搭建,包括虚拟环境、依赖包安装和基础项目配置 | + +## 小结 + +1. **原型设计收尾:** 完成剩余页面的原型设计工作,确保原型完整性; +2. **技术基础夯实:** 重点学习FastAPI框架基础知识和项目结构,打好后端开发基础; +3. **数据库设计理解:** 积极参与数据库设计讨论,深入理解数据模型设计思路; +4. **开发环境准备:** 提前搭建好开发环境,为后续编码工作做好准备; +5. **团队协作学习:** 向有经验的组员学习后端设计思路,提升个人技术水平; +6. **希望获得的帮助:** 希望组织关于"FastAPI入门实践"或"后端项目结构规范"的有趣小班课,帮助快速掌握后端开发基础知识。 \ No newline at end of file diff --git a/doc/process/weekly/week-05/members/wanglirong-weekly-summary-05.md b/doc/process/weekly/week-05/members/wanglirong-weekly-summary-05.md new file mode 100644 index 0000000..fc6ed7d --- /dev/null +++ b/doc/process/weekly/week-05/members/wanglirong-weekly-summary-05.md @@ -0,0 +1,54 @@ +# 个人周总结-第5周 + +## 基本信息 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间**: 2025-10-20 +**结束时间**: 2025-10-26 + +--- + +## 本周任务完成情况 + +| 序号 | 计划内容 | 完成状态 | 完成情况说明 | +|------|----------|----------|--------------| +| 1 | 完善界面原型 | 已完成 | 与伊木然协作,按时完成了管理员系列页面和名词解释页面的补充设计,界面完整性和用户体验得到提升 | +| 2 | 参与数据库设计讨论 | 已完成 | 积极参与团队数据库设计讨论,对表结构设计和字段约束有了更深入的理解 | +| 3 | FastAPI框架基础学习 | 进行中 | 初步学习了FastAPI路由设置、请求处理、响应模型等核心概念,但还未掌握基础用法 | +| 4 | 后端项目结构学习 | 已完成 | 通过研究GitHub优秀项目,了解了标准的FastAPI项目结构和代码组织规范 | +| 5 | 参与需求评审准备 | 已完成 | 协助团队整理技术方案,为向老师汇报做好了充分准备 | +| 6 | 撰写需求规格书技术部分 | 已完成 | 按时完成了需求规格说明书中技术架构和数据库设计相关内容的撰写 | +| 7 | 后端开发环境搭建 | 已完成 | 提前完成FastAPI开发环境搭建,包括虚拟环境配置、依赖包安装和基础项目设置 | + +--- + +## 成果与收获 + +1. **技术能力提升:** 掌握了FastAPI框架的基础知识,理解了后端项目标准结构,为后续开发工作奠定了坚实基础 +2. **原型设计能力:** 通过完善管理员页面和名词解释页面,提升了界面设计思维和用户体验意识 +3. **团队协作经验:** 在数据库设计和需求评审准备过程中,与团队成员有效沟通协作,提升了团队合作能力 +4. **文档撰写能力:** 参与技术文档撰写,锻炼了技术表达和文档编写能力 + +--- + +## 遇到的问题与解决方案 + +1. **FastAPI学习曲线:** 初次接触FastAPI时对异步编程概念理解不够深入,通过查阅官方文档和实战练习逐步掌握 +2. **项目结构理解:** 对大型项目结构组织方式存在困惑,通过分析GitHub上的优秀项目案例,理清了模块划分思路 +3. **时间管理挑战:** 多项任务并行时时间安排紧张,通过制定详细日计划和优先级排序,确保各项任务按时完成 + +--- + +## 改进方向与建议 + +1. **技术深度:** 需要进一步深入学习FastAPI的高级特性和最佳实践 +2. **实战经验:** 希望尽快开始实际编码工作,将理论知识转化为实践能力 +3. **学习资源:** 建议团队共享优质学习资料和技术文档,提高学习效率 + +--- + +## 下周学习需求 + +1. 需要更多实际编码练习机会,巩固所学知识 +2. 期待与后端同学有更多交流,互相学习 \ No newline at end of file diff --git a/doc/process/weekly/week-05/members/yimuran-weekly-plan-05.md b/doc/process/weekly/week-05/members/yimuran-weekly-plan-05.md new file mode 100644 index 0000000..ded432b --- /dev/null +++ b/doc/process/weekly/week-05/members/yimuran-weekly-plan-05.md @@ -0,0 +1,28 @@ +# 个人周计划-第5周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-10-20 + +**结束时间:** 2025-10-26 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **完善界面原型** | 个人 | 2025-10-20至2025-10-22使用Axure完成“名词解释页面”的交互和视觉设计优化。 | +| 2 | **后端技术分工及学习** | 个人 | 2025-10-20至2025-10-26,系统了解FastAPI框架。等明确分工后,学习对应的后端技术。 | +| 3 | **数据库设计** | 后端开发人员 | 2025-10-24到2025-10-27,参与小组关于数据库库表结构的讨论,协助绘制ER图,明确字段与约束。 | +| 4 | **需求评审与确认** | 组员 | 2025-10-24,准备并参与向指导老师的成果汇报会议,记录关键反馈,以便进一步明确需求。 | +| 5 | **撰写需求规格书** | 组员 | 2025-10-26,根据会议评审意见,与组员协作完成V1.0需求规格说明书初稿中后端功能相关部分的撰写。 | +| 6 | **一周总结** | 个人 | 2025-10-26,整理本周学习笔记和工作成果,复盘任务完成情况,并参与制定小组下周计划。 | + +## 小结 + +1. **技能学习:** 本周的核心任务是了解FastAPI框架,目标是掌握其基础知识并研究,为后端开发打下基础。 +2. **原型制作:** 负责“名词解释页面”的收尾工作,注重交互细节和用户体验,确保原型达到提交评审的标准 +3. **积极参与协作**:本周涉及多项团队协作任务,我将积极参与数据库设计和需求文档撰写,并在需求评审会议上清晰地阐述自己的设计思路,为项目的顺利推进贡献力量。 \ No newline at end of file diff --git a/doc/process/weekly/week-05/members/yimuran-weekly-summary-05.md b/doc/process/weekly/week-05/members/yimuran-weekly-summary-05.md new file mode 100644 index 0000000..e23b455 --- /dev/null +++ b/doc/process/weekly/week-05/members/yimuran-weekly-summary-05.md @@ -0,0 +1,33 @@ +# 个人周总结-第5周 + +## 姓名和起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-10-20 + +**结束时间:** 2025-10-26 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | ------------------------------ | -------- | ------------------------------------------------------------ | +| 1 | **完善界面原型** | 完成 | 2025-10-22,按计划完成了“名词解释页面”的交互设计和视觉优化,确保了该页面的功能性和用户体验。 | +| 2 | **后端技术学习** | 进行中 | 2025-10-20至2025-10-26,系统学习了 FastAPI 框架的基础知识,包括路由、请求处理等方面。后续将根据确定的后端分工,深入学习具体模块所需的技术。 | +| 3 | **数据库设计** | 完成 | 2025-10-24至2025-10-26,积极参与了小组关于数据库表结构的讨论,根据原型和交互流程,协助完成了ER图的绘制,并明确了相关字段和约束。 | +| 4 | **与指导老师沟通确认原型界面** | 完成 | 2025-10-24,在讨论群内与老师关于原型界面和AI调优进行了讨论 | +| 5 | **完成项目需求规格书** | 部分完成 | 周内只完成了需求规格内容的整理,目前还需要将内容填充到规范的项目需求规格文档中,并补足缺漏和不足。 | +| 6 | **一周总结** | 完成 | 2025-10-26,整理了本周的学习笔记(特别是FastAPI相关内容)和原型工作成果,反思了任务完成情况,并参与了小组下周计划的制定。 | + +## 对团队工作的建议 + +1. **细化后端分工:** 建议尽快明确后端三个角色的具体负责人,以便成员可以更有针对性地深入学习和准备后续开发。 +2. **加强技术交流:** 建议后端成员之间定期进行技术分享和交流,特别是针对FastAPI、异步编程等难点,共同解决学习中遇到的问题。 + +## 小结 + +1. **原型收尾:** 本周完成了分配的原型完善任务,对Axure工具的使用更加熟练。 +2. **后端学习启动:** 通过系统学习,对FastAPI框架有了初步了解,为后续深入学习和承担后端开发任务奠定了基础。 +3. **团队协作:** 积极参与了数据库设计、需求评审和文档撰写等团队协作任务,对项目的整体理解更加深入。 \ No newline at end of file diff --git a/doc/process/weekly/week-06/group/meeting-minutes-06.md b/doc/process/weekly/week-06/group/meeting-minutes-06.md new file mode 100644 index 0000000..942c94e --- /dev/null +++ b/doc/process/weekly/week-06/group/meeting-minutes-06.md @@ -0,0 +1,100 @@ +# 小组会议纪要-第6周 + +## 会议记录概要 + +**团队名称:** 3班-葫芦娃救bug + +**指导老师:** 李友焕 + +**主 持 人:** 项目经理 (PM) + +**记录人员:** 李文韬 + +**会议主题:** 项目前景与范围文档评审及下周任务分配 + +**会议地点:** 天马二区一栋A01 + +**会议时间:** 2025-11-02 13:00-14:30 + +**记录时间:** 2025-11-02 17:00 + +**参与人员:** 全体成员 + +## 会议内容 + +**下周开发计划讨论:** + +- 后端团队下周将启动开发环境搭建。 +- 确定统一使用 **Linux**(WSL)作为后端开发环境。 +- 梁峻耀将负责搭建后端框架、数据库及依赖。团队讨论了使用 Radmin 组建局域网以共享开发数据库的方案。 + +**原型修改讨论:** + +- 为配合管理员的审核需求(判断是否封禁用户),会议确定原型需要增加 **“用户活跃度报表”** 页面,用于展示用户的查询次数和资源利用情况。 +- 会议确定需要增加 **“管理员公告”** 的发布和维护功能。 +- 以上原型修改将同步添加到《用例文档》中。 + +**《项目前景与范围文档》评审:** + +- 团队逐条评审了《项目前景与范围文档》V1.1 版本。 + +- 会议决定对 V1.1 的功能范围进行删减: + + 删除了“多人协作,并发冲突”相关描述和功能。 + + 决定将“回滚操作”和“执行预估”功能放到后续版本实现。 + + 删除了“对模糊需求主动向用户澄清”的功能。 + + 删除了“系统内数据加密处理”的需求。 + +- 团队还讨论并修改了文档中的性能指标,结合大模型实际响应情况,将响应时间从数秒量级调整为数十秒。 + +**小班课任务分配:** + +- **原型讲解:** 明确了原型界面的展示和讲解任务,在成员讨论后,最终确定由 **李文韬** 负责。 +- **数据库讲解:** 确定由 **梁峻耀** 负责数据库部分的讲解,要求使用 **Power Design** 工具绘制图表并进行演示。如果工作量过大,李文韬将协助绘制。 + +## 后续任务安排 + +**(1)项目主要任务方面:** + +- **需求规格文档的编写**:由李果霖、王利蓉、伊木然负责编写第一版《需求规格文档》。 +- **技术学习**:前后端开发人员按分工继续技术栈学习 +- **原型完善**:李文韬负责,增加“用户活跃度报表”和“管理员公告”页面。 +- **小班课讲解准备**:李文韬准备原型讲解,梁峻耀使用 Power Design 完善数据库设计并准备讲解。 + +**(2)具体任务安排:** + +- **会议记录**:梁峻耀 +- **会议主持**:王利蓉 +- **文档管理**:伊木然 +- **周计划与周总结**:伊木然 +- **进度管理**:梁峻耀 +- **日常考勤**:王利蓉 + +**(3)会议时间安排:** + +- **例会时间**:星期日晚上7:00-10:00 +- **技术讨论时间**:星期日晚上7:00-10:00 + +## 问题总结 + +### 已解决问题: + +1. 完成了《项目前景与范围文档》的评审,并确定对V1.1版本的修改意见和功能范围。 +2. 明确了下周小班课原型和数据库的讲解负责人。 +3. 明确了原型需新增的页面。 + +### 待解决问题: + +1. 前后端交互的接口文档尚未开始定义。 +2. 后端的开发环境尚未搭建。 + +## 小组协作情况总结 + +1. **协作情况:** 团队协作顺畅,会议期间成员能够围绕原型和需求文档进行有效沟通和讨论。 + +## 一周纪律情况总结 + +1. **纪律情况:** 小组成员均按时参与会议,并完成了各自在第六周计划中的任务。 \ No newline at end of file diff --git a/doc/process/weekly/week-06/group/weekly-plan-06.md b/doc/process/weekly/week-06/group/weekly-plan-06.md new file mode 100644 index 0000000..98176d5 --- /dev/null +++ b/doc/process/weekly/week-06/group/weekly-plan-06.md @@ -0,0 +1,25 @@ +# 小组周计划-第6周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-27 +**结束时间:** 2025-11-02 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 执行人 | 情况说明 | +|------|----------|--------|----------| +| 1 | 进一步完善、美化原型界面 | 李果霖 | 结合在上一周展示原型时表现出的部分缺漏(AI对话框交互,用户上传头像、管理员管理用户卡片限制取值),进一步完善、美化原型界面。 | +| 2 | 前端技术学习 | 前端开发人员 | 学习react钩子函数,Route跳转;学会使用Ant design UI组件,调研前端实现报表功能的难度(Chart.js、ECharts、Recharts等)。 | +| 3 | 后端技术学习 | 后端开发人员 | 熟悉python,python异步协程,学习fastapi的与前端交互的部分,了解前后端联调的流程。 | +| 4 | 后端任务分配 | 后端开发人员 | 现在后端任务已分为三部分,在了解完后端技术的基本情况后,后端人员结合了解的情况和个人兴趣认领其中一部分边学边做。 | +| 5 | AI微调成本研究 | AI负责人员 | 调研算力平台(AutoDL、阿里云PAI、Lambda Labs)对比价格、显存、训练时长、QLoRA 支持情况,学习 LoRA/QLoRA 原理,整理微调流程步骤 | +| 6 | 完善需求规格书 | 全体成员 | PM 下发文档模板与分工(如:功能需求、非功能需求、数据流等),每人负责对应章节,周三前提交草稿, 周四晚团队评审,周五定稿 | + +## 小结 + +1. 本周的主要内容分为两部分,一是前端研究能否跑通项目逻辑,目前的关键难点是能否在前端根据后端返回的数据形成报表(目前只考虑饼状图和柱状图),前端报表渲染若无法在周五前验证成功,选择备用方案(静态图替代等),二是后端需要尽快熟悉python、python异步协程和FastAPI基础,跑通前后端联调的流程,并认领任务角色边学边做。 +1. AI模型调优方面的工作主要以调研学习为主,现在已经明确的是TextToSQL模型的参数量大致在14B左右,这是综合性能和成本的结果,AI 算力成本若超预期,需及时调整模型规模或训练策略,调优方法主要关注LoRA和QLoRA。 +1. 本周需要利用已有的需求规格文档补充完善项目的需求规格文档,需要PM明确目标、分配任务,把控进度。 + diff --git a/doc/process/weekly/week-06/group/weekly-summary-06.md b/doc/process/weekly/week-06/group/weekly-summary-06.md new file mode 100644 index 0000000..77cfbe3 --- /dev/null +++ b/doc/process/weekly/week-06/group/weekly-summary-06.md @@ -0,0 +1,32 @@ +# 小组周总结-第6周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-10-27 + +**结束时间:** 2025-11-02 + +### 本周任务完成情况 + +### 本周任务完成情况 + +| 序号 | 内容总结 | 是否完成 | 情况说明 | +| ---- | ---------------------------- | ---------- | ------------------------------------------------------------ | +| 1 | **进一步完善、美化原型界面** | 完成 | 李果霖根据上周反馈,优化了AI对话框交互逻辑,完善了用户头像上传功能,并调整了管理员管理用户卡片的取值限制,整体界面交互更流畅、视觉更统一。 | +| 2 | **前端技术学习** | 持续进行中 | 前端开发人员(李果霖、李文韬)学习了React Hooks 和路由跳转机制,初步掌握 Ant Design 组件库的使用;同时调研了 Chart.js、ECharts 和 Recharts 等报表方案,确认可在项目中实现饼图与柱状图渲染。 | +| 3 | **后端技术学习** | 持续进行中 | 后端开发人员(梁峻耀、王利蓉、伊木然)深入学习 Python 异步协程与 FastAPI 基础,完成简单 API 示例开发,初步理解前后端联调流程。 | +| 4 | **后端任务分配** | 完成 | 后端三人根据兴趣与能力,已认领各自负责模块(用户与项目管理、对话与消息模块、SQL执行与结果处理),进入“边学边做”阶段。 | +| 5 | **AI微调成本研究** | 持续进行中 | AI负责人员完成对 AutoDL、阿里云 PAI、Lambda Labs 的平台对比,整理出各平台在价格、显存、训练时长及 QLoRA 支持方面的优劣,并初步掌握 LoRA/QLoRA 微调原理与流程。 | +| 6 | **需求规格说明书撰写** | **未完成** | 原计划本周定稿,因内容复杂度高、需与用例和范围文档对齐,**整体推迟至第7周启动**。 | +| 7 | **用例文档第一稿** | 完成 | 全体成员协作完成用例文档初稿,覆盖核心用户场景与系统交互流程,并于周四完成内部评审,收集修改意见。 | +| 8 | **前景与范围文档** | 完成 | 明确项目目标、边界、关键干系人与成功标准,完成文档撰写并组织第一次团队讨论与评审,为后续开发提供指引。 | + +### 小结 + +**原型与文档**:本周在界面原型方面完成关键交互细节优化,同时需求规格说明书经过全员协作,已按模板规范完成定稿,为后续开发奠定坚实基础。 + +**前后端技术学习取得阶段性成果**:前端掌握 React 核心概念与 UI 组件使用,并验证了报表渲染可行性;后端完成 FastAPI 初步实践,明确分工后进入模块化开发准备阶段。 + +**AI方向聚焦成本与可行性**:通过系统调研主流算力平台,明确了微调训练的资源边界,为后续模型选型(如13B规模Text-to-SQL模型)和训练策略(QLoRA)提供了决策依据。 diff --git a/doc/process/weekly/week-06/members/liangjunyao-weekly-plan-06.md b/doc/process/weekly/week-06/members/liangjunyao-weekly-plan-06.md new file mode 100644 index 0000000..278bb34 --- /dev/null +++ b/doc/process/weekly/week-06/members/liangjunyao-weekly-plan-06.md @@ -0,0 +1,28 @@ +# 个人周计划-第6周 + +## 姓名与起止时间 + +**姓 名**:梁峻耀 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-27 + +**结束时间**:2025-11-02 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ------------------------ | ------------------- | ------------------------------------------------------------ | +| 1 | 后端技术深入学习 | 个人 | 2025-10-28 前完成 Python 异步协程和 FastAPI 基础学习,重点掌握路由定义、请求/响应模型、依赖注入等核心机制。 | +| 2 | 搭建后端整体项目框架 | 个人 | 2025-11-02 前尝试完成 FastAPI 项目基础结构搭建,包括:目录结构(如 api/、models/、schemas/、core/、database/)、数据库连接配置(PostgreSQL + SQLAlchemy/ORM)、全局异常处理、环境变量管理(.env),并提交至 Git 仓库供组员复用。 | +| 3 | 后端任务认领与分工 | 后端开发人员 | 2025-10-30 组织后端小组会议,根据小组周计划第4项,结合成员兴趣与能力,认领三部分后端任务(如用户管理、AI接口对接、报表数据服务等),明确初步开发排期。 | +| 4 | 数据库结构细化与接口设计 | 后端开发人员 | 基于第5周完成的 ER 图和表结构,2025-11-01 前完成核心模块(如用户、对话记录、报表数据)的建表语句及对应 FastAPI 接口定义(含请求/响应格式)。 | +| 5 | 参与需求规格书评审与定稿 | 全体成员 | 按小组计划,2025-10-30 提交所负责章节草稿(如“数据流”或“非功能需求”),2025-10-31 参与团队评审,2025-11-01 前完成修改并定稿。 | +| 6 | 验证前后端联调最小闭环 | 个人 / 前端开发人员 | 2025-11-02 前实现一个可运行的 FastAPI 接口(如获取用户信息),配合前端完成一次完整请求-响应联调,验证流程可行性。 | + +## 小结 + +1. **后端推进重点**:本周核心目标是跑通 FastAPI 最小联调闭环,并完成后端任务分工与整体框架搭建,为下周全员进入编码阶段提供统一基础。 +2. **后端整体项目框架**:尝试搭建项目后端的整体框架,将逻辑分层,代码分区,为后续代码开发奠定基础。 +3. **文档协同**:按时完成需求规格书分工内容,确保技术实现与需求一致。 diff --git a/doc/process/weekly/week-06/members/liangjunyao-weekly-summary-06.md b/doc/process/weekly/week-06/members/liangjunyao-weekly-summary-06.md new file mode 100644 index 0000000..7a43ffd --- /dev/null +++ b/doc/process/weekly/week-06/members/liangjunyao-weekly-summary-06.md @@ -0,0 +1,29 @@ +# 个人周总结-第6周 + +## 姓名与起止时间 + +**姓 名**:梁峻耀 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-27 + +**结束时间**:2025-11-02 + +## 本周任务完成情况 + +| 序号 | 计划内容 | 是否完成 | 情况说明 | +| ---- | ------------------------ | ---------- | ------------------------------------------------------------ | +| 1 | 后端技术深入学习 | 完成 | 按计划于2025-11-01前完成了 Python 异步协程和 FastAPI 核心机制的学习,重点掌握了路由定义、请求/响应模型及依赖注入的使用方法。 | +| 2 | 搭建后端整体项目框架 | 持续进行中 | 成功搭建了基于 FastAPI 的后端基础项目结构,完成目录分层(api/、models/、schemas/、core/、database/),正在配置 PostgreSQL 与 SQLAlchemy ORM 连接,并实现全局异常处理与环境变量管理。 | +| 3 | 后端任务认领与分工 | 完成 | 组织后端小组会议,结合成员兴趣与能力完成任务认领,明确了用户管理、AI接口对接、报表数据服务等模块的开发排期。 | +| 4 | 数据库结构细化 | 部分完成 | 基于第5周完成的 ER 图和发送变化的需求,对数据库库表设计进行了调整,目前在power designer的绘图正在进行。 | +| 5 | 参与需求工程相关文档工作 | 调整完成 | 原计划参与《需求规格说明书》撰写,因整体进度调整,**该任务已推迟至第7周**。本周转而参与《用例文档》第一稿写作,并完成团队评审;同时完成《前景与范围文档》的初稿撰写,并参与首次讨论与修订。 | +| 6 | 验证前后端联调最小闭环 | 完成 | 实现了一个获取用户信息的 FastAPI 接口,并与前端配合完成请求-响应联调,验证了前后端交互流程的可行性。 | + +## 小结 + +1. **后端框架搭建与联调验证**:本周成功搭建了结构清晰、分层合理的后端项目框架,并完成了前后端最小闭环联调,为后续开发奠定了坚实基础。 +2. **任务分工明确与团队协作顺畅**:通过组织后端小组会议,明确了各成员的任务分工与排期,团队协作效率得到提升。 +3. **文档与技术实现同步推进**:在完成技术任务的同时,积极参与《前景与范围文档》和《用例文档》的评审与修订,确保开发工作与项目需求保持一致。 + diff --git a/doc/process/weekly/week-06/members/liguolin-weekly-plan-06.md b/doc/process/weekly/week-06/members/liguolin-weekly-plan-06.md new file mode 100644 index 0000000..87e343d --- /dev/null +++ b/doc/process/weekly/week-06/members/liguolin-weekly-plan-06.md @@ -0,0 +1,27 @@ +# 个人周计划-第6周 + +## 姓名与起止时间 + +**姓 名**:李果霖 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-27 + +**结束时间**:2025-11-02 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | -------------------------- | ---------- | ------------------------------------------------------------ | +| 1 | **原型界面完善与美化** | 个人 | 重点解决第五周原型演示时发现的细节问题,包括优化AI对话框的交互逻辑、实现用户上传头像功能以及为管理员管理用户的卡片增加状态选择的交互限制。 | +| 2 | **前端关键技术学习与调研** | 个人 | 深入学习React钩子函数和Route路由跳转。同时,重点调研并实践前端报表功能,对比Chart.js、ECharts、Recharts等方案,目标是在周五前验证图表(饼状图、柱状图)能否根据数据动态生成。 | +| 3 | **完善需求规格书** | 全体成员 | 按照PM分配的章节,负责撰写文档中与前端界面、用户交互及功能需求相关的部分。计划周三前提交个人负责的草稿,周四参与团队评审,并于周五完成定稿。 | +| 4 | **参与AI微调方案调研** | AI负责人员 | 协同AI负责人,了解不同算力平台(如AutoDL、阿里云PAI)在价格、性能上的差异,并学习LoRA/QLoRA的微调原理,为团队技术选型提供参考。 | +| 5 | **整理个人笔记与周报** | 个人 | 整理本周在React、报表库和AI微调方面的学习笔记,回顾任务完成情况,并撰写个人周总结。 | + +## 小结 + +1. **文档协作**:作为团队的一员,将积极参与《需求规格说明书》的撰写工作。我会按照时间节点(周三交稿,周五定稿)完成自己负责的部分,确保文档的完整性和准确性。 +2. **核心攻坚**:本周的首要任务是完善并美化前端原型,修正已知问题。同时,技术攻坚的重点是前端报表功能的实现,必须在周五前拿出一个可行的技术方案(动态渲染或静态图片等备用方案),确保项目逻辑能够跑通。 +3. **持续学习**:在完成开发任务的同时,继续深化对React框架的理解(特别是钩子函数和路由),并关注AI微调的技术进展,保持知识的同步更新。 \ No newline at end of file diff --git a/doc/process/weekly/week-06/members/liguolin-weekly-summary-06.md b/doc/process/weekly/week-06/members/liguolin-weekly-summary-06.md new file mode 100644 index 0000000..ec33799 --- /dev/null +++ b/doc/process/weekly/week-06/members/liguolin-weekly-summary-06.md @@ -0,0 +1,34 @@ +# 个人周总结-第6周 + +## 姓名和起止时间 + +姓  名: 李果霖 + +团队名称: 3班-葫芦娃救bug + +开始时间: 2025-10-27 + +结束时间: 2025-11-02 + +## 本周任务完成情况 + +| **序号** | **总结内容** | **是否完成** | **情况说明** | +| -------- | -------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **原型界面完善与美化** | 完成 | 按照计划,重点解决了第五周原型演示时发现的细节问题,包括优化AI对话框的交互逻辑、实现用户头像上传功能,并为管理员管理用户的卡片增加了状态选择的交互限制。 | +| 2 | **前端关键技术学习与调研** | 完成 | 深入学习了React Hooks(钩子函数)和Route路由跳转。同时,重点调研并对比了ECharts、Recharts等方案,确认了在项目中实现饼图和柱状图的动态渲染是可行的。 | +| 3 | **完善需求规格书** | 未完成 | 原计划本周定稿,但根据团队周总结,此任务因复杂度高,已推迟至第7周启动。第6周会议已明确由我、王利蓉和伊木然负责编写第一版。 | +| 4 | **参与AI微调方案调研** | 完成 | 协同AI负责人,完成了对AutoDL、阿里云PAI等算力平台的对比调研,并学习了LoRA/QLoRA的微调原理,为团队技术选型提供了参考。 | +| 5 | **参与文档评审** | 完成 | 参与了《项目前景与范围文档》V1.1版本的评审 和《用例文档》初稿的内部评审。 | +| 6 | **整理个人笔记与周报** | 完成 | 整理了本周关于React、报表库和AI微调方面的学习笔记,并完成了本份周总结。 | + +## 对团队工作的建议 + +1. **加快后端环境搭建**:第六周会议已确定后端团队下周启动开发环境搭建。建议尽快完成统一(Linux/WSL)和框架搭建,这是下周启动前后端联调的前提。 +2. **启动接口文档定义**:目前团队的待解决问题是前后端交互的接口文档尚未开始定义。建议后端同学在搭建框架时,同步输出第一版API接口文档,便于前端进行数据模拟和对接。 +3. **开始大模型微调工作**:搭建微调代码框架,选用qlora微调大模型。后续可手动进行第二轮的数据清洗和标注 + +## 小结 + +1. **完成原型细节优化**:本周按计划完成了第5周遗留的原型细节优化,包括AI对话框交互、头像上传和管理员卡片的状态限制,使原型交互逻辑更加完善。 +2. **攻克报表技术难点**:重点调研了ECharts等前端报表库,并成功验证了动态渲染饼图和柱状图的可行性。这为下周原型新增“用户活跃度报表”功能 扫清了技术障碍。 +3. **明确下周文档任务**:虽然本周的需求规格书(SRS)撰写任务被推迟,但在周日会议上已明确了该任务是第7周的核心工作,我将按分工负责相应章节的编写。 \ No newline at end of file diff --git a/doc/process/weekly/week-06/members/liwentao-weekly-plan-06.md b/doc/process/weekly/week-06/members/liwentao-weekly-plan-06.md new file mode 100644 index 0000000..c2111ba --- /dev/null +++ b/doc/process/weekly/week-06/members/liwentao-weekly-plan-06.md @@ -0,0 +1,30 @@ +# 个人周计划-第6周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-10-27 + +**结束时间**:2025-11-02 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | -------------- | ---------- | ------------------------------------------------------------ | +| 1 | 前端技术学习 | 个人 | 学习react钩子函数,Route跳转;学会使用Ant design UI组件,调研前端实现报表功能的难度(Chart.js、ECharts、Recharts等)。 | +| 2 | AI微调成本研究 | AI负责人员 | 调研算力平台(AutoDL、阿里云PAI、Lambda Labs)对比价格、显存、训练时长、QLoRA 支持情况 | +| 3 | AI微调学习 | AI负责人员 | 学习 LoRA/QLoRA 原理,整理微调流程步骤 | +| 4 | 一周总结 | 个人 | 2025-11-02整理一周文档,参与制定小组下周计划,并结合小组计划制定个人计划 | + + + +## 小结 + +1. **前端技术:** 个人任务重点是深入学习高级框架功能(React hooks、路由)和UI组件库(Ant design),同时对关键的报表(图表)功能进行技术选型和难度预研。 +2. **AI微调:** A同时推进理论学习和实践调研。一方面需要学习LoRA/QLoRA等微调方法的原理并梳理流程(任务5);另一方面要对算力平台(AutoDL、阿里云PAI等)进行成本、性能及技术支持(QLoRA/LoRA)的对比研究。 +3. **常规总结:** 计划在2025-11-02完成本周的文档整理,并参与制定团队及个人的下一周计划。 + + \ No newline at end of file diff --git a/doc/process/weekly/week-06/members/liwentao-weekly-summary-06.md b/doc/process/weekly/week-06/members/liwentao-weekly-summary-06.md new file mode 100644 index 0000000..06f55a5 --- /dev/null +++ b/doc/process/weekly/week-06/members/liwentao-weekly-summary-06.md @@ -0,0 +1,32 @@ + +# 个人周总结-第6周 + +## 姓名和起止时间 + +**姓  名:** 李文韬 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-27 +**结束时间:** 2025-11-02 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | -------------- | ---------- | ------------------------------------------------------------ | +| 1 | 前端技术学习 | 持续进行中 | 深入学习了 React 钩子函数(Hooks)及 React Router 的路由跳转机制。初步掌握了 Ant Design UI 组件库的使用方法。同时,按计划调研了前端报表方案,具体研究了 Chart.js、ECharts 和 Recharts 等库,确认使用这些方案在项目中实现饼图与柱状图渲染的难度可控,技术可行。 | +| 2 | AI微调成本研究 | 完成 | 完成对 AutoDL、阿里云 PAI、Lambda Labs 的平台对比,整理出各平台在价格、显存、训练时长及 QLoRA 支持方面的优劣 | +| 3 | AI微调学习 | 持续进行中 | 已初步掌握 LoRA/QLoRA 的微调原理。同时,已开始着手整理详细的微调流程步骤文档。 | +| 4 | 前景与范围文档 | 完成 | (计划外任务)完成了《前景与范围文档》的撰写。文档中明确了项目目标、功能与技术边界、关键干系人以及项目成功标准。已与团队完成了第一次讨论与评审。 | +| 5 | 一周总结 | 完成 | 2025-11-03,整理了一周文档,参与制定了小组下周计划,并结合小组计划制定了个人计划。 | + + + +## 对团队工作的建议 + +1. 尽快从需求获取与分析阶段转向代码开发阶段。 +1. 团队加强沟通协作。 + +## 小结 + +1. **技术学习:** 前端技术初步掌握,可以开始开发;AI微调方法继续按计划学习。 +2. **文档撰写:**前景与范围文档撰写完成 。 +3. **常规工作:** 按时完成了本周总结与下周的计划制定。 diff --git a/doc/process/weekly/week-06/members/wanglirong-weekly-plan-06.md b/doc/process/weekly/week-06/members/wanglirong-weekly-plan-06.md new file mode 100644 index 0000000..31876fd --- /dev/null +++ b/doc/process/weekly/week-06/members/wanglirong-weekly-plan-06.md @@ -0,0 +1,24 @@ +# 个人周计划-第6周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-27 +**结束时间:** 2025-11-02 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | 学习 Python 及异步协程 | 个人 | 结合文档和教程,掌握 Python 异步编程基础,理解协程在服务端开发中的应用。 | +| 2 | 学习 FastAPI 与前端交互 | 个人 | 学习 FastAPI 的基础使用,包括路由、请求响应模型、中间件等,掌握与前端联调的基本流程。 | +| 3 | 后端任务认领与初步实现 | 后端开发人员 | 结合个人兴趣与技术方向,认领后端任务模块,并开始边学边做,尝试实现基础接口。 | +| 4 | 参与需求规格书撰写 | 全体成员 | 根据 PM 分工,完成需求规格书中负责的章节内容,按时提交草稿并参与团队评审。 | +| 5 | 前后端联调流程了解 | 前端开发人员 | 了解并模拟一次前后端联调流程,确保能配合前端完成数据接口的调试与对接。 | + +## 小结 + +1. **技术学习重点:** 本周重点在于掌握 Python 异步编程与 FastAPI 框架,为后续后端开发打下基础; +2. **任务推进:** 尽快认领后端任务并开始实践,结合学习内容逐步实现接口功能; +3. **协作沟通:** 积极参与需求文档撰写与团队评审,保持与前端同学的沟通,确保联调流程顺畅。 diff --git a/doc/process/weekly/week-06/members/wanglirong-weekly-summary-06.md b/doc/process/weekly/week-06/members/wanglirong-weekly-summary-06.md new file mode 100644 index 0000000..631dfdc --- /dev/null +++ b/doc/process/weekly/week-06/members/wanglirong-weekly-summary-06.md @@ -0,0 +1,33 @@ +# 个人周总结-第6周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-10-27 +**结束时间:** 2025-11-02 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | 学习Python异步协程 | 完成 | 通过官方文档和教程学习了Python异步编程基础,理解了协程在服务端开发中的应用场景 | +| 2 | 学习FastAPI与前端交互 | 完成 | 掌握了FastAPI的路由设置、请求响应模型等基础知识,了解了与前端联调的基本流程 | +| 3 | 后端任务认领与初步实现 | 完成 | 结合个人技术方向认领了后端任务模块,并开始尝试实现基础接口功能 | +| 4 | 参与需求规格书撰写 | 未完成 | 需求规格说明书推迟到第七周开始书写 | +| 5 | 了解前后端联调流程 | 完成 | 与前端同学沟通了解了联调流程,为后续接口调试做好了准备 | +| 6 | 参与用例文档撰写与讨论 | 完成 | 积极参与用例文档的撰写和团队讨论,提出了建设性意见 | +| 7 | 参与前景与范围文档审核 | 完成 | 对前景与范围文档进行了初步审核和讨论改进,初步确定了第一搞 | + +## 对团队工作的建议 + +1. **技术分享机制:** 建议建立定期的技术分享会,让各方向负责人员分享学习心得和技术难点,促进团队整体技术水平提升; +2. **文档协作效率:** 推荐使用在线协作工具进行文档编写和评审,提高文档协作效率; +3. **进度同步机制:** 建议每周中期进行一次进度快速同步,及时发现和解决阻塞问题。 + +## 小结 + +1. **技术学习成果:** 完成了Python异步编程和FastAPI框架的基础学习,为后端开发奠定了坚实基础; +2. **文档贡献:** 积极参与用例文档,前景与范围文档等多份重要文档的撰写和评审工作; +3. **任务推进:** 顺利认领后端开发任务并开始实践,从学习阶段逐步过渡到开发阶段; +4. **团队协作:** 保持良好的团队沟通,与前端同学就联调流程达成共识,确保后续开发顺畅。 diff --git a/doc/process/weekly/week-06/members/yimuran-weekly-plan-06.md b/doc/process/weekly/week-06/members/yimuran-weekly-plan-06.md new file mode 100644 index 0000000..fae0467 --- /dev/null +++ b/doc/process/weekly/week-06/members/yimuran-weekly-plan-06.md @@ -0,0 +1,38 @@ +# 个人周计划-第6周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-10-27 + +**结束时间:** 2025-11-02 + +## 本周核心目标 + +1. 掌握前后端核心通信机制(HTTP+JSON)及 Postman 的基本使用。 +2. 学习 FastAPI 基础,能够运行简单的 API 服务。 +3. 根据团队讨论结果,认领具体的后端开发角色(用户与项目管理 / 对话与指令解析 / 执行与结果处理)。 +4. 深入学习所认领角色需要的特定技术栈(如 JWT、AI 调用、Celery 等)。 +5. 参与需求规格说明书的完善工作,负责后端相关章节。 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **学习共识基础** | 后端全体 | 2025-10-27至11-02 学习 HTTP 方法 (GET/POST)、JSON 格式、API 路径、状态码等核心概念。使用 Postman 发送请求,理解前后端交互基础。需要尽快熟悉python、python异步协程和FastAPI基础,跑通前后端联调的流程。 | +| 2 | **FastAPI 基础学习** | 个人 | 2025-10-28,学习 FastAPI 官方教程基础部分,掌握路由、请求处理、Pydantic 模型的基本用法。 | +| 3 | **后端角色与任务认领** | 后端开发人员 | 2025-10-29(暂定),参与后端小组会议,讨论并认领三个后端角色之一(用户与项目管理 / 对话与指令解析 / 执行与结果处理)。 | +| 4 | **专精技术学习** | 个人 | 2025-10-30 至 2025-11-01,**根据认领的角色**,深入学习所需的技术栈。例如: - **角色A**: 学习 JWT 认证、调用 AI 模型1 等。 - **角色B**: 学习管理对话上下文、调用 AI 模型2、SQL 解析等。 - **角色C**: 学习 `asyncpg`、Celery 异步任务、Redis 等。 | +| 5 | **完善需求规格书** | 全体成员 | 2025-10-29前,完成负责章节初稿。2025-10-30晚,参与评审并记录修改意见。2025-10-31完成定稿。 | +| 6 | **初步实践与环境搭建** | 个人 | 2025-11-01 至 2025-11-02,根据认领的角色和学习的技术,搭建本地开发环境,尝试编写与角色相关的简单 FastAPI 接口。 | +| 7 | **一周总结** | 个人 | 2025-11-02,整理本周学习笔记和工作成果,复盘任务完成情况,并参与制定小组下周计划。 | + +## 小结 + +1. **学习策略调整:** 本周重点是先建立后端团队的技术共识(HTTP+JSON+Postman),然后根据分工进行专精技术的深入学习,避免重复劳动和学习范围过广。 +2. **核心共识:** 理解 API 如何通信是协作的基础,务必保证所有后端成员都能看懂 Postman 请求和基本的 JSON 数据。 +3. **任务承接:** 明确个人负责的角色后,集中精力学习该角色所需的核心技术,做到“边学边做”,为后续具体开发任务做准备。 +4. **文档协作:** 按时完成需求规格说明书中分配的章节,积极参与团队评审,确保文档准确反映后端设计。 \ No newline at end of file diff --git a/doc/process/weekly/week-06/members/yimuran-weekly-summary-06.md b/doc/process/weekly/week-06/members/yimuran-weekly-summary-06.md new file mode 100644 index 0000000..8b7cae8 --- /dev/null +++ b/doc/process/weekly/week-06/members/yimuran-weekly-summary-06.md @@ -0,0 +1,28 @@ +# 个人周总结-第6周 + +## 姓名和起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + + **开始时间:** 2025-10-27 + +**结束时间:** 2025-11-02 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | -------------------------------- | ---------- | ------------------------------------------------------------ | +| 1 | **学习后端共识基础** | 完成 | 通过官方文档和教程学习了Python异步编程基础,理解了协程在服务端开发中的应用场景, 重点了解路由定义、请求/响应模型及依赖注入的使用方法。 | +| 2 | **FastAPI 基础学习** | 完成 | 2025-10-28,按照计划学习了 FastAPI 官方教程,掌握了路由定义,请求处理的基本用法。 | +| 3 | **后端角色与任务认领** | 完成 | 2025-10-29,参与了后端小组分工会议,根据团队分工(用户管理、AI接口、任务调度),结合个人技术方向认领了后端任务模块 | +| 5 | **参与需求规格书撰写** | 未完成 | 需求规格说明书推迟到第七周开始书写 | +| 6 | **初步实践与环境搭建** | 持续进行中 | 2025-11-01起,开始搭建个人的本地开发环境(WSL + FastAPI),并尝试编写与用户管理相关的 API 接口。 | +| 7 | **参与前景与范围文档撰写与审核** | 完成 | 积极参与前景与范围文档的编写,并进行了初步审核和讨论改进,初步确定了第一搞。并参与《用例文档》团队评审。 | + +## 小结 + +1. **角色认领完成**:后端任务分工已经明确(我负责第一部分),“边学边做”的阶段正式开始,后续将专注于 JWT 认证。 +2. **文档与技术实现同步推进**:在完成技术任务的同时,积极参与《前景与范围文档》和《用例文档》的评审与修订,确保开发工作与项目需求保持一致。 + diff --git a/doc/process/weekly/week-07/group/meeting-minutes-07.md b/doc/process/weekly/week-07/group/meeting-minutes-07.md new file mode 100644 index 0000000..cc1467a --- /dev/null +++ b/doc/process/weekly/week-07/group/meeting-minutes-07.md @@ -0,0 +1,126 @@ +# 小组会议纪要-第7周 + +## 会议记录概要 +**团队名称**: 3班-葫芦娃救bug +**指导老师**: 李友焕 +**主 持 人**: 项目经理 (PM) +**记录人员**: 梁峻耀 +**会议主题**: 需求文档评审、小班课原型演示预演、数据库设计优化、前后端联调 +**会议地点**: 天马二区一栋A01 +**会议时间**: 2025-11-06 15:07-17:30 +**记录时间**: 2025-11-09 18:00 +**参与人员**: 全体成员 + +## 会议内容 + +### 1. 需求文档评审 +- **文档版本确认**:对《需求规格说明书》V0.2版本进行了全面评审,确认文档结构完整,内容符合项目目标 +- **功能需求优化**: + - 优化系统响应流程,将"系统验证"调整为"用户等待系统执行",使描述更贴合实际交互过程 + - 增加对危险操作的确认机制,确保DML操作(INSERT/UPDATE/DELETE)前必须经过用户确认 + - 明确AI服务调用规则,确认AI操作必须由用户明确触发,不能自动执行 +- **非功能需求确认**: + - 确立90%的可用性指标,基于用户操作成功次数与总操作次数的比率 + - 强化安全需求,增加危险操作审计功能,记录所有对数据库的修改操作 + - 确认系统可维护性要求,支持模块化替换与升级 + +### 2. 小班课原型演示预演 +- **演示流程确认**: + - 由李文韬负责原型界面讲解,重点展示用户工作区、对话界面和报表功能 + - 梁峻耀负责数据库设计讲解,使用Power Design工具展示表结构和关系 + - 确认演示顺序:先用户端功能,再管理员功能,最后数据库设计 +- **界面功能优化**: + - 增加"用户活跃度报表"页面,展示用户查询次数和资源使用情况,辅助管理员决策 + - 添加"管理员公告"功能,支持草稿/已发布状态管理,增强系统通知能力 + - 优化封禁/解封流程,在登录页面提供申诉入口,提升用户体验 +- **核心业务流程演示**: + - 演练了从自然语言描述到数据库创建的完整流程 + - 演示了SQL查询、结果展示及导出功能 + - 展示了管理员对用户行为的监控与管理能力 + +### 3. 数据库设计优化 +- **表结构完善**: + - 最终确认包含15张核心表的设计方案,较前期增加了用户活动表、用户状态表和违规操作日志表 + - 为用户表增加登录IP、最后登录时间等字段,强化用户行为追踪 + - 设计独立的用户状态表,记录封禁/解封状态及变更原因 +- **关键表设计**: + - **用户活动表**:记录用户登录、登出、设置变更等行为,包含操作时间、IP地址、操作详情 + - **违规操作日志表**:记录异常登录、可疑查询等行为,为自动封禁提供数据支持 + - **查询结果表**:优化设计,支持存储历史版本,便于数据对比和回溯 + - **公告表**:增加状态字段(草稿/已发布),支持内容版本管理 +- **审计机制**: + - 确认所有用户操作和管理员操作均需记录 + - 设计专门的申诉流程表,记录用户解封申请和管理员处理结果 + - 为所有关键表添加创建时间、修改时间字段,确保操作可追溯 + +### 4. 前后端联调 +- **环境搭建**: + - 尝试使用Radmin组建局域网,实现开发环境共享 + - 配置MySQL允许远程连接,解决局域网内数据库访问问题 + - 确认前后端均部署在Linux(WSL)环境下,保证环境一致性 +- **接口测试**: + - 验证FastAPI基础接口可用性,测试GET请求通信 + - 讨论并规划核心API接口,包括用户认证、对话管理、数据库操作等 + - 确认使用HTTPS保证通信安全 +- **技术挑战**: + - 解决跨域资源共享(CORS)问题,配置FastAPI允许前端域名访问 + - 优化MySQL远程连接配置,修改权限设置允许局域网IP访问 + - 讨论前后端数据格式,确认JSON作为主要交换格式 + +## 后续任务安排 + +### (1)项目主要任务方面: +- **需求文档**:完成《需求规格说明书》版本,整合评审意见 +- **原型完善**:根据小班课反馈,优化界面交互和功能展示 +- **数据库实现**:完成15张表的SQL脚本编写和测试 +- **前后端开发**: + - 后端:搭建FastAPI基础框架,实现用户认证模块 + - 前端:熟悉API文档,开始对接后端接口 + - 联调:解决跨域和远程连接问题,确保基础通信正常 +- **演示准备**:进一步演练小班课演示内容,确保流程顺畅 + +### (2)具体任务安排: +- 会议记录:梁峻耀 +- 会议主持:王利蓉 +- 文档管理:李果霖 +- 周计划与周总结:李文韬 +- 进度管理:李果霖 +- 日常考勤:王利蓉 +- 数据库开发:梁峻耀、李文韬、李果霖 +- 前端开发:李果霖、李文韬 +- 后端开发:梁峻耀、王利蓉、伊木然 +- 测试验证:全体成员 + +### (3)会议时间安排: +- 例会时间:星期日晚上7:00-10:00 +- 技术讨论时间:星期日晚上7:00-10:00 +- 临时会议:根据开发需要,通过团队群组协调 + +## 问题总结 + +### 已解决问题: +- 完成《需求规格说明书》V0.2版本评审,确定优化方向 +- 确认小班课演示分工和流程,明确各自负责内容 +- 优化数据库设计,增加用户活动、状态管理相关表结构 +- 验证局域网内前后端基础通信可行性 +- 确定用户封禁/解封流程及申诉机制设计 + +### 待解决问题: +- MySQL远程连接配置问题,需修改权限设置允许局域网访问 +- 前后端跨域资源共享(CORS)配置尚未完成 +- 用户申诉功能的具体实现细节需要进一步讨论 +- 查询结果历史版本功能的前端展示方案待确定 +- AI服务与系统集成的调用机制需要明确 + +## 小组协作情况总结 +**协作情况**: 本周团队协作高效,成员们在会议中积极参与讨论,针对需求文档、原型设计和数据库结构提出了建设性意见。特别是在前后端联调环节,团队成员互相协助,共同解决了环境配置问题,体现了良好的技术协作精神。 + +## 一周纪律情况总结 +**纪律情况**: 小组纪律良好,全体成员按时参加本次会议。会议期间专注讨论,有效利用时间,完成了需求评审、原型预演、数据库优化和联调测试等重要任务。 + +## 备注 +- 本次会议重点是为小班课做准备,同时推进系统核心功能开发 +- 数据库设计已基本稳定,后续重点是实现前后端联调和核心业务逻辑 +- 需特别关注用户安全审计功能,这是系统的重要特色 +- 下周例会将重点关注API文档确认与核心功能实现进度 +- 建议团队成员提前学习FastAPI和React相关知识,为开发工作做好准备 \ No newline at end of file diff --git a/doc/process/weekly/week-07/group/weekly-plan-07.md b/doc/process/weekly/week-07/group/weekly-plan-07.md new file mode 100644 index 0000000..fd8aa7c --- /dev/null +++ b/doc/process/weekly/week-07/group/weekly-plan-07.md @@ -0,0 +1,32 @@ +# 小组周计划-第7周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-03 + +**结束时间:** 2025-11-09 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 执行人 | 情况说明 | +|------|----------|--------|----------| +| 1 | **准备小班课讲解内容** | 李文韬, 梁峻耀 | **[本周重点]** 根据分工,李文韬负责准备原型界面的演示和讲解;梁峻耀负责使用 Power Design 准备数据库 ER 图和表结构的讲解。 | +| 2 | **原型界面最终完善** | 李文韬 | 配合小班课准备,根据第六周会议评审意见,完成"用户活跃度报表"和"管理员公告"页面的原型设计。 | +| 3 | **后端开发环境搭建** | 梁峻耀, 后端开发人员 | 搭建基于 Linux (WSL) 的统一开发环境,安装 FastAPI 框架、PostgreSQL 数据库及相关依赖,并尝试通过 Radmin 组建局域网共享开发数据库。 | +| 4 | **V1.0 需求规格书撰写** | 李果霖, 王利蓉, 伊木然 | 根据第六周评审后(已删减功能范围)的《项目前景与范围文档》,完成 V1.0 需求规格说明书的初稿撰写。 | +| 5 | **V1.0 接口文档定义** | 前后端开发人员 | **[本周重点]** 解决"接口文档尚未定义"的问题。前后端共同定义 V1.0 的核心 API 接口(如用户注册/登录、创建项目、发送消息、执行SQL等),明确请求/响应的 JSON 格式。 | +| 6 | **前端项目框架搭建** | 前端开发人员 | 使用 React 搭建前端项目骨架,完成基础路由配置和主要页面布局(对接原型)。 | +| 7 | **后端模块开发启动** | 后端开发人员 | 根据已认领的角色(用户与项目管理、对话与消息引擎、执行与结果处理),开始开发 V1.0 接口文档中已定义的基础接口。 | +| 8 | **用例文档评审完善** | 李果霖, 全体成员 | 在用例文档第一稿基础上,组织团队评审,收集反馈意见,完成用例文档的修改和完善。确定最终稿。 | +| 9 | **前景与范围文档完善+定稿** | 王利蓉, 梁峻耀 | 基于前期讨论和评审意见,完成前景与范围文档的内容补充,之后全体成员评审 | + +## 小结 + +1. **本周核心目标**是完成小班课的准备(任务1、2)和正式启动项目开发(任务3、5、6、7)。 + +2. **文档收尾**:需求规格书(任务4)、用例文档(任务8)和前景与范围文档(任务9)是开发阶段的重要依据,相关同学需确保内容与上周评审会确定的范围一致。 + +3. **开发启动**:本周是从"学习"转向"开发"的关键一周。后端环境搭建(任务3)和接口定义(任务5)是启动编码的前提,必须优先完成。 + diff --git a/doc/process/weekly/week-07/group/weekly-summary-07.md b/doc/process/weekly/week-07/group/weekly-summary-07.md new file mode 100644 index 0000000..47ee1c3 --- /dev/null +++ b/doc/process/weekly/week-07/group/weekly-summary-07.md @@ -0,0 +1,31 @@ +# 小组周总结-第7周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-03 + +**结束时间:** 2025-11-09 + +### 本周任务完成情况 + + | 序号 | 内容总结 | 是否完成 | 情况说明 | + | ---- | --------------------------- | ------------ | ------------------------------------------------------------ | + | 1 | **准备小班课讲解内容** | 完成 | **[本周重点]** 李文韬、梁峻耀已按分工,分别完成了原型界面的演示准备和使用 Power Design 绘制的数据库 ER 图及表结构讲解材料。 | + | 2 | **原型界面最终完善** | 完成 | 李文韬已配合小班课讲解需求,根据第六周会议评审意见,完成了"用户活跃度报表"和"管理员公告"两个新页面的原型设计。 | + | 3 | **后端开发环境搭建** | 完成 | 梁峻耀已带领后端开发人员,完成了统一开发环境,并通过 Radmin 初步实现了局域网共享数据库。 | + | 4 | **V1.0 需求规格书撰写** | 完成 | 李果霖, 王利蓉, 伊木然已根据第六周评审后的《项目前景与范围文档》,协作完成了 V1.0 需求规格说明书的初稿。 | + | 5 | **V1.0 接口文档定义** | **进行中** | 完成基本的定义工作,计划下一周完成定稿。 | + | 6 | **前端项目框架搭建** | 前端开发人员 | 使用 React 搭建前端项目骨架,完成基础路由配置和主要页面布局(对接原型)。 | + | 7 | **后端模块开发启动** | **进行中** | 后端开发人员已根据认领的角色,准备根据下周的接口文档正式启动了各自模块(用户管理、消息引擎、SQL执行)的基础接口编码工作。 | + | 8 | **用例文档评审完善** | 完成 | 在李果霖的组织下,全体成员共同评审了用例文档第一稿,并完成了修改定稿。 | + | 9 | **前景与范围文档完善+定稿** | 完成 | 王利蓉, 梁峻耀已根据评审意见完成了文档的补充,并通过了团队最终评审,正式定稿。 | + +### 小结 + +**开发阶段正式启动**:本周成功完成了从“学习规划”到“编码开发”的关键过渡。后端统一开发环境已搭建。 + +**核心文档全部收尾**:对《用例文档》和《前景与范围文档》进行了最终评审和定稿,开发目标和范围进一步明确。 + +**阶段性成果准备就绪**:用于小班课讲解的最终版原型(已新增报表和公告页面)和详细的数据库设计(Power Design ER图)均已准备完成,清晰地展示了蓝图。 \ No newline at end of file diff --git a/doc/process/weekly/week-07/members/liangjunyao-weekly-plan-07.md b/doc/process/weekly/week-07/members/liangjunyao-weekly-plan-07.md new file mode 100644 index 0000000..55685da --- /dev/null +++ b/doc/process/weekly/week-07/members/liangjunyao-weekly-plan-07.md @@ -0,0 +1,25 @@ +# 个人周计划-第7周 + +## 姓名与起止时间 + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-11-03 +**结束时间**:2025-11-09 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | --------------------------- | ---------------------- | ------------------------------------------------------------ | +| 1 | **准备小班课数据** | 个人 | **[本周重点]** 使用 Power Design 绘制并完善数据库 ER 图与表结构,准备讲解材料,确保逻辑清晰、表达准确。 | +| 2 | **后端开发环境统一搭建** | 个人 | 在 WSL 中完成 FastAPI + PostgreSQL 环境的配置,尝试通过 Radmin 搭建局域网共享数据库,确保组员可连接使用。 | +| 3 | **Redis和Celery学习** | 个人 | 下载python Redis热门项目,学习Redis在实际项目中的使用场景和使用技巧。搜索Celery视频教程学习Celery技术。 | +| 4 | **V1.0 接口文档定义与评审** | 前后端开发人员 | **[本周重点]** 参与核心 API 接口的定义(如用户登录、执行SQL、消息发送等),明确请求/响应格式,组织接口评审会议。 | +| 5 | **协作规范** | 后端开发人员 | 确认环境配置与启动流程,规定标准编码方式,便于组员协同开发。 | +| 6 | **参与需求规格书初稿评审** | 李果霖, 王利蓉, 伊木然 | 参与 V1.0 需求规格说明书的初稿评审,确保技术实现与需求一致,提出修改建议。 | + +## 小结 + +1. **讲解、开发与学习**:本周需同时完成小班课数据库讲解准备(任务1)、开发环境搭建与接口开发启动(任务4)与Redis和Celery学习(任务3),时间分配需合理。 +2. **接口设计**:接口文档(任务4)是前后端协作的基石,务必在本周明确并评审通过,避免后续返工。 +3. **环境搭建**:在搭建环境的同时可以进行简单的测试,尽早暴露环境或依赖问题,确保开发流程顺畅。 diff --git a/doc/process/weekly/week-07/members/liangjunyao-weekly-summary-07.md b/doc/process/weekly/week-07/members/liangjunyao-weekly-summary-07.md new file mode 100644 index 0000000..06e5601 --- /dev/null +++ b/doc/process/weekly/week-07/members/liangjunyao-weekly-summary-07.md @@ -0,0 +1,26 @@ +# 个人周总结 - 第7周 + +**姓  名**: 梁峻耀 +**团队名称**: 3班-葫芦娃救bug +**开始时间**: 2025-11-03 +**结束时间**: 2025-11-09 + +## 本周任务完成情况 + +| 序号 | 计划内容 | 是否完成 | 情况说明 | +| ---- | -------------------------- | -------- | ------------------------------------------------------------ | +| 1 | 准备小班课数据 | 完成 | 使用 PowerDesigner 完善了数据库 ER 图与表结构,整理了讲解逻辑与关键设计点,并配合李文韬完成小班课联合演示,数据库部分讲解清晰、逻辑严谨。 | +| 2 | 后端开发环境统一搭建 | 完成 | 在 WSL2 中成功搭建了基于 FastAPI + PostgreSQL 的统一开发环境,配置了 SQLAlchemy ORM 连接、环境变量管理及全局异常处理;通过 Radmin 实现局域网内数据库共享,组员可正常连接访问。 | +| 3 | Redis 和 Celery 学习 | 部分完成 | 完成了 Redis 在缓存与审计日志中的使用方式学习,阅读了 Python Redis 的典型项目源码;Celery 仅完成基础概念与任务调度机制的学习,实践集成部分留待下周结合项目需求进行。 | +| 4 | V1.0 接口文档定义与评审 | 进行中 | 前后端开发人员完成了核心 API 的初步定义,包括用户登录/注册、项目创建、SQL 执行与消息发送等接口,明确请求/响应 JSON 格式,并组织了接口评审会议。但文档仍需进一步完善,计划下周完成最终定稿。 | +| 5 | 协作规范制定 | 完成 | 与后端成员共同确认了项目目录结构、依赖管理方式(使用 venv + requirements.txt)、启动脚本及编码风格规范,为后续高效协同开发奠定基础。 | +| 6 | 参与需求规格书初稿评审 | 完成 | 参与 V1.0《需求规格说明书》初稿评审会议,从技术实现角度提出若干修改建议(如字段可空性、接口权限粒度等),确保文档与开发可行性一致。 | +| 7 | 《项目前景与范围文档》完善 | 完成 | 与王利蓉一起根据团队评审意见对《项目前景与范围文档》V1.1版本进行补充修改,确认功能范围删减(移除多人协作、回滚操作等特性),调整性能指标,并通过团队最终评审正式定稿。 | + +## 小结 + +**开发准备**: 本周是项目从"准备"转向"开发"的关键节点。成功搭建统一后端环境、推进接口定义、制定协作规范,为后续模块化开发扫清障碍。与团队共同完成了《项目前景与范围文档》的最终定稿,明确了V1.0版本的范围边界。 + +**小班课交付**: 通过清晰的数据库设计讲解,展现了扎实的系统设计能力,配合李文韬的原型演示,为团队在小班展示中争取了良好反馈。演示过程中重点体现了15张核心表的设计理念,特别是新增的用户活动表、状态表和违规操作日志表。 + +**学习与实践**: 在完成紧急开发任务的同时,持续学习 Redis 与 Celery 等关键技术,为后续实现 SQL 执行缓存、异步任务与审计日志功能做好技术储备。同时,对FastAPI框架和SQLAlchemy ORM进行了深入实践,确保技术能力与项目需求匹配。 \ No newline at end of file diff --git a/doc/process/weekly/week-07/members/liguolin-weekly-plan-07.md b/doc/process/weekly/week-07/members/liguolin-weekly-plan-07.md new file mode 100644 index 0000000..bfe2185 --- /dev/null +++ b/doc/process/weekly/week-07/members/liguolin-weekly-plan-07.md @@ -0,0 +1,28 @@ +# 个人周计划-第7周 + +## 姓名与起止时间 + +**姓 名**:李果霖 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-11-03 + +**结束时间**:2025-11-09 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | -------------------------- | -------------- | ------------------------------------------------------------ | +| 1 | **V1.0 需求规格书撰写** | 王利蓉, 伊木然 | **[本周重点]** 这是第6周推迟的核心任务。本周需根据第6周会议评审后(已删减功能范围)的《项目前景与范围文档》,完成 V1.0 需求规格说明书的初稿撰写。 | +| 2 | **V1.0 接口文档定义** | 前后端开发人员 | **[本周重点]** 解决第6周“接口文档尚未定义”的问题。作为前端开发,将与后端同事共同定义V1.0的核心API接口(如用户注册/登录、项目管理、发送消息等)。 | +| 3 | **前端项目框架搭建** | 前端开发人员 | 从“学习”转向“开发”。使用React搭建前端项目骨架,完成基础路由配置和主要页面布局,为对接原型和V1.0接口做准备。 | +| 4 | **支持AI微调框架** | AI负责人员 | 根据第6周的个人建议,协同AI负责人,关注QLoRA微调代码框架的搭建进展,提供必要支持。 | +| 5 | **参与前景与范围文档评审** | 全体成员 | 参与前景与范围文档的最终评审。 | +| 6 | **整理个人笔记与周报** | 个人 | 整理本周在SRS撰写、接口定义和React项目搭建过程中的笔记,回顾任务完成情况,并撰写个人周总结。 | + +## 小结 + +1. **文档收尾**:本周的首要任务是完成第6周推迟的需求规格书(SRS)撰写。我将按团队分工(李果霖、王利蓉、伊木然),基于最新的功能范围完成相应章节。 +2. **启动开发**:本周是项目从学习转向开发的关键周。我将作为前端主力,积极参与V1.0接口文档的定义(任务2),并同步搭建React项目的前端骨架(任务3),为后续的联调工作打好基础。 +3. **技术落地**:基于第6周对ECharts等报表库的可行性验证,在搭建前端框架时,将预留好报表组件的接口和位置,确保第7周原型新增的“用户活跃度报表”功能 能在后续开发中顺利集成。 \ No newline at end of file diff --git a/doc/process/weekly/week-07/members/liguolin-weekly-summary-07.md b/doc/process/weekly/week-07/members/liguolin-weekly-summary-07.md new file mode 100644 index 0000000..9119a84 --- /dev/null +++ b/doc/process/weekly/week-07/members/liguolin-weekly-summary-07.md @@ -0,0 +1,34 @@ +# 个人周总结-第7周 + +## 姓名和起止时间 + +**姓名**:李果霖 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-11-03 + +**结束时间**:2025-11-09 + +## 本周任务完成情况 + +| **序号** | **总结内容** | **是否完成** | **情况说明** | +| -------- | -------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **V1.0需求规格书撰写** | 完成 | **【本周重点】**按计划完成了第6周推迟的核心任务。根据最新功能范围,与王利蓉、伊木然协作完成了V1.0需求规格书的初稿撰写。 | +| 2 | **V1.0 接口文档定义** | 进行中 | **【本周重点】**已完成基本定义工作,计划于下周完成定稿。 | +| 3 | **前端项目框架搭建** | 完成 | 从“学习”转向“开发”。已使用React搭建基础项目,完成路由配置与主要页面布局,为对接V1.0接口做好准备。 | +| 4 | **支持AI微调框架** | 完成 | 协助AI负责人,跟进QLoRA代码框架的搭建进展,并提供必要支持。 | +| 5 | **参与前景与范围文档评审** | 完成 | 参与了前景与范围文档的最终评审。 | +| 6 | **整理个人笔记与周报** | 完成 | 整理了本周在SRS撰写、接口定义和React项目搭建过程中的笔记,并完成本份周总结。 | + +## 对团队工作的建议 + +1. **启动联调**:本周V1.0接口定义已在进行中,前端框架也已完成搭建。建议下周(第8周)优先完成接口文档定稿,并立即启动首轮前后端联调,打通用户注册/登录和项目列表功能。 +2. **加速Alpha版本开发**:下周应全面进入Alpha版本的功能开发,前端需将V1.0接口实现到代码中,替换原有的模拟数据。 +3. **启动AI微调**:AI账户框架已开始搭建,建议AI负责人加速推进第一次微调,以便评估模型效果与算力消耗。 + +## 小结 + +1. **完成文档收尾工作**:本周按计划完成了第6周推迟的核心文档任务,特别是《V1.0需求规格书》的初稿撰写。 +2. **成功启动开发阶段**:本周是项目从学习转向开发的关键周。作为前端主力,我积极参与了V1.0接口文档的定义(进行中),并同步搭建了React项目框架,为后续联调打下基础。 +3. **技术方案落地**:在本次搭建的前端框架中,已根据第6周对ECharts等报表库的可行性验证,预留了报表组件的接口和位置,确保后续“用户活跃度报表”等功能能顺利集成。 diff --git a/doc/process/weekly/week-07/members/liwentao-weekly-plan-07.md b/doc/process/weekly/week-07/members/liwentao-weekly-plan-07.md new file mode 100644 index 0000000..bb6ef45 --- /dev/null +++ b/doc/process/weekly/week-07/members/liwentao-weekly-plan-07.md @@ -0,0 +1,34 @@ +# 个人周计划-第7周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-11-03 + +**结束时间**:2025-11-09 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | 准备小班课讲解内容 | 梁峻耀 | **[本周重点]**根据分工,负责准备原型界面的演示和讲解。 | +| 2 | 原型界面最终完善 | 个人 | 配合小班课的准备工作,根据第六周会议评审意见,完成"用户活跃度报表"和"管理员公告"页面的原型设计。 | +| 3 | V1.0 接口文档定义 | 后端开发人员 | **[本周重点]**作为前端开发人员,与后端共同定义 V1.0 的核心 API 接口(用户注册/登录、执行SQL等),明确请求/响应的 JSON 格式。 | +| 4 | 前端项目框架搭建 | 李果霖 | 作为前端开发人员,使用 React 搭建前端项目骨架,完成基础路由配置和主要页面布局(对接原型)。 | +| 5 | 参与用例文档评审 | 全体成员 | 参与团队评审,整理反馈意见,协助完成用例文档的修改、完善及定稿。 | +| 6 | 参与前景与范围文档评审 | 全体成员 | 参与前景与范围文档的最终评审。 | +| 7 | 一周总结 | 个人 | 2025-11-09整理一周文档,参与制定小组下周计划,并结合小组计划制定个人计划 | + + + +## 小结 + +1. **核心任务:** 本周的个人工作重点是完成小班课的准备。这包括两部分:一是完成"用户活跃度报表"和"管理员公告"的最终原型设计;二是准备原型界面的演示和讲解材料。 +2. **开发启动:** 作为前端开发人员,本周将正式启动开发工作。首要任务是与后端协作,共同定义 V1.0 的核心 API 接口文档。同时,开始搭建 React 前端项目框架,包括基础路由和页面布局。 +3. **文档评审:** 参与团队的文档收尾工作,包括评审用例文档和前景与范围文档,确保开发依据明确统一。 +4. **常规总结:** 计划在2025-11-09完成本周的文档整理,并参与制定团队及个人的下一周计划。 + + \ No newline at end of file diff --git a/doc/process/weekly/week-07/members/liwentao-weekly-summary-07.md b/doc/process/weekly/week-07/members/liwentao-weekly-summary-07.md new file mode 100644 index 0000000..af990f3 --- /dev/null +++ b/doc/process/weekly/week-07/members/liwentao-weekly-summary-07.md @@ -0,0 +1,40 @@ + +# 个人周总结-第7周 + +## 姓名和起止时间 + +**姓  名:** 李文韬 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-03 +**结束时间:** 2025-11-09 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | ---------------------- | -------- | ------------------------------------------------------------ | +| 1 | 准备小班课讲解内容 | 完成 | 根据分工,负责准备了原型界面的演示和讲解材料,已在小班课上完成讲解。 | +| 2 | 原型界面最终完善 | 完成 | 配合小班课的准备工作,根据第六周会议评审意见,完成了"用户活跃度报表"和"管理员公告"页面的原型设计。 | +| 3 | V1.0 接口文档定义 | 进行中 | **[本周重点]** 作为前端开发人员,与后端共同定义了 V1.0 的核心 API 接口(用户注册/登录、执行SQL等),完成了基本定义工作。计划在第8周完成最终定稿。 | +| 4 | 前端项目框架搭建 | 完成 | 参与了团队评审,并协助完成了用例文档的修改、完善及定稿。 | +| 5 | 参与用例文档评审 | 完成 | 参与了团队评审,并协助完成了用例文档的修改、完善及定稿。 | +| 6 | 参与前景与范围文档评审 | 完成 | 参与了前景与范围文档的最终评审,文档已正式定稿并提交。 | +| 7 | 一周总结 | 完成 | 2025-11-09 整理了本周文档,参与制定了小组下周计划,并结合小组计划制定了个人计划。 | + + + +## 对团队工作的建议 + + + +- **接口定稿:** 第7周接口文档已在推进中,且前后端的基础框架均已搭建完毕。建议第8周将接口文档定稿作为最优先事项,以便后端开发人员 和前端开发人员 能立即投入编码。 +- **编码推进:** 第7周已完成所有核心文档的定稿(如用例和前景范围),团队已成功从规划转入开发阶段。建议下周全面铺开编码工作,前端需将原型布局 与真实接口对接,后端也应基于接口定义 加速模块实现。 +- **AI模型实践:** 鉴于第6周已完成AI微调的理论研究和成本分析,建议AI负责人从下周开始将研究转向实践,着手搭建代码框架并准备数据集,尽快进行首次实验。 + +## 小结 + + + +1. **核心任务:** 本周的个人工作重点已完成,即小班课的汇报。这包括完成了"用户活跃度报表"和"管理员公告"的最终原型设计,以及原型界面的演示讲解材料。 +2. **开发启动:** 作为前端开发人员,本周已正式启动开发工作。已与李果霖协作完成了 React 前端项目框架的搭建。同时,与后端协作定义 V1.0 核心 API 接口文档,目前状态为“进行中”,将在下周定稿。 +3. **文档评审:** 参与了团队的文档收尾工作,所在的团队已完成了《用例文档》和《前景与范围文档》的最终评审和定稿。 +4. **常规总结:** 2025-11-09 已按计划完成本周的文档整理,并参与制定了团队及个人的下一周计划。 diff --git a/doc/process/weekly/week-07/members/wanglirong-weekly-plan-07.md b/doc/process/weekly/week-07/members/wanglirong-weekly-plan-07.md new file mode 100644 index 0000000..3006d67 --- /dev/null +++ b/doc/process/weekly/week-07/members/wanglirong-weekly-plan-07.md @@ -0,0 +1,27 @@ +# 个人周计划-第7周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-03 +**结束时间:** 2025-11-09 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | V1.0需求规格书撰写 | 李果霖、伊木然 | 根据评审后的《项目前景与范围文档》,完成V1.0需求规格说明书中负责章节的初稿撰写 | +| 2 | 前景与范围文档完善+定稿 | 梁峻耀、全体成员 | 基于前期讨论和评审意见,完成前景与范围文档的内容补充,参与团队评审并完成定稿 | +| 3 | 后端开发环境搭建 | 梁峻耀、后端开发人员 | 搭建基于Linux(WSL)的统一开发环境,安装FastAPI框架、PostgreSQL数据库及相关依赖 | +| 4 | V1.0接口文档定义 | 前后端开发人员 | 参与定义V1.0核心API接口,明确用户注册/登录、创建项目、发送消息等接口的请求/响应JSON格式 | +| 5 | 后端模块开发启动 | 后端开发人员 | 根据已认领的角色,开始开发V1.0接口文档中已定义的基础接口 | +| 6 | 用例文档评审完善 | 全体成员 | 参与用例文档的团队评审,提供修改意见,协助完成用例文档的最终定稿 | + +## 小结 + +1. **文档工作重点:** 本周需要完成需求规格书撰写和前景与范围文档的完善定稿,确保文档质量符合开发要求; +2. **技术开发启动:** 重点参与后端环境搭建和模块开发,实现从学习到开发实践的过渡; +3. **接口规范制定:** 积极参与V1.0接口文档定义,为前后端联调奠定基础; +4. **希望获得的帮助:** 希望有关于FastAPI项目实战和数据库设计优化的技术分享,提升后端开发效率。 + diff --git a/doc/process/weekly/week-07/members/wanglirong-weekly-summary-07.md b/doc/process/weekly/week-07/members/wanglirong-weekly-summary-07.md new file mode 100644 index 0000000..1bd2b76 --- /dev/null +++ b/doc/process/weekly/week-07/members/wanglirong-weekly-summary-07.md @@ -0,0 +1,36 @@ +# 个人周总结-第7周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-03 +**结束时间:** 2025-11-09 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | V1.0需求规格书撰写 | 完成 | 与李果霖、伊木然协作,按时完成了需求规格说明书中负责章节的初稿撰写 | +| 2 | 前景与范围文档完善+定稿 | 完成 | 参与团队评审,基于前期讨论意见完成了前景与范围文档的内容补充和定稿 | +| 3 | 后端开发环境搭建 | 完成 | 与梁峻耀等后端开发人员共同完成了基于Linux(WSL)的统一开发环境搭建 | +| 4 | V1.0接口文档定义 | 进行中 | 与前端开发人员共同推进V1.0核心API接口定义,已完成基本框架,计划在第8周完成最终定稿 | +| 5 | 后端模块开发启动 | 部分完成 | 根据认领角色开始了基础接口开发,但受限于接口文档未完全定稿,部分功能开发暂缓 | +| 6 | 用例文档评审完善 | 完成 | 参与了用例文档的团队评审,提供了建设性修改意见 | + +## 对团队工作的建议 + +1. **接口定稿优先:** 建议第8周将接口文档定稿作为最优先事项,为前后端开发提供明确依据; +2. **开发进度同步:** 建议建立每日站会机制,及时同步开发进度和遇到的问题; +3. **技术难点预研:** 针对后端复杂功能模块,建议提前进行技术预研,避免开发过程中出现重大技术障碍; +4. **联调准备:** 建议提前规划前后端联调流程,确保接口实现后能够快速验证。 + +## 小结 + +1. **文档工作成果:** 顺利完成了需求规格书、前景与范围文档等重要文档的撰写和定稿工作; +2. **开发基础奠定:** 后端开发环境已搭建完成,为Alpha版本开发做好了技术准备; +3. **接口定义推进:** 积极参与V1.0接口文档定义工作,与前端保持密切沟通; +4. **开发准备就绪:** 后端开发工作已准备就绪,待接口文档定稿后即可全面展开; +5. **团队协作顺畅:** 与团队成员协作良好,在文档评审和接口讨论中积极贡献; +6. **需要技术支持:** 希望在FastAPI高级应用和数据库优化方面获得更多实战指导。 + diff --git a/doc/process/weekly/week-07/members/yimuran-weekly-plan-07.md b/doc/process/weekly/week-07/members/yimuran-weekly-plan-07.md new file mode 100644 index 0000000..8af05c4 --- /dev/null +++ b/doc/process/weekly/week-07/members/yimuran-weekly-plan-07.md @@ -0,0 +1,28 @@ +# 个人周计划-第7周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-03 + +**结束时间:** 2025-11-09 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 执行人 | 情况说明 | +| ---- | ----------------------- | --------------------------- | ------------------------------------------------------------ | +| 1 | **V1.0 需求规格书撰写** | 个人 (与李果霖、王利蓉协作) | 2025-11-03 至 2025-11-05,根据第六周评审会确定的功能范围,负责撰写 V1.0 需求规格说明书中与“用户管理”和“项目管理”功能相关的章节。 | +| 2 | **后端开发环境搭建** | 个人 | 2025-11-04前,在梁峻耀同学的主导下,共同搭建基于 Linux (WSL) 的统一开发环境,安装FastAPI框架、PostgreSQL数据库及相关依赖 | +| 3 | **V1.0 接口文档定义** | 前后端开发人员 | **[本周重点]** 参与核心 API 接口的定义(如用户登录、执行SQL、消息发送等),明确请求/响应格式,组织接口评审会议。 | +| 4 | **协作规范** | 后端开发人员 | 确认环境配置与启动流程,规定标准编码方式,便于组员协同开发。 | +| 5 | **专精技术学习** | 个人 | 2025-11-03 至 2025-11-09 深入学习并实践 FastAPI 中的 JWT 认证和依赖注入。 | +| 6 | **团队文档管理与周报** | 个人 | 2025-11-09,整理本周的接口文档、需求规格书等产出并归档;撰写个人周总结,并起草小组第七周周总结 | + +## 小结 + +1. **开发正式启动**:本周是从学习和准备转向实际编码的关键一周。核心任务是搭建好环境. +2. **接口先行**:V1.0 接口文档(任务3)是本周最重要的产出之一,必须与前后端同学充分讨论并达成一致,以确保后续开发可以并行。 +3. **文档收尾**: 本周需要完成需求规格书撰写和前景与范围文档的完善定稿,确保文档质量符合开发要求。 \ No newline at end of file diff --git a/doc/process/weekly/week-07/members/yimuran-weekly-summary-07.md b/doc/process/weekly/week-07/members/yimuran-weekly-summary-07.md new file mode 100644 index 0000000..b2271cf --- /dev/null +++ b/doc/process/weekly/week-07/members/yimuran-weekly-summary-07.md @@ -0,0 +1,28 @@ +# 个人周总结-第7周 + +## 姓名和起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-03 + +**结束时间:** 2025-11-09 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | ------------------------ | -------------- | ------------------------------------------------------------ | +| 1 | **V1.0 需求规格书撰写** | 完成 | 2025-11-05前,按计划与组员协作,完成了V1.0需求规格说明书中“用户管理”和“项目管理”章节的初稿撰写。 | +| 2 | **后端开发环境搭建** | 完成 | 2025-11-04前,与后端同学共同在WSL上搭建了统一的FastAPI和PostgreSQL开发环境,安装了相关依赖。 | +| 3 | **V1.0 接口文档定义** | **进行中** | 按计划与组员协作,完成基本的定义工作,计划下一周完成定稿。 | +| 4 | **协作规范确认** | **进行中** | 参与讨论并确认了后端的环境配置、启动流程和标准编码方式,为协同开发奠定了基础。 | +| 5 | **专精技术学习 (角色A)** | **持续进行中** | 深入学习FastAPI中的JWT认证(`passlib`, `fastapi.security`)和依赖注入机制,为模块开发做好了技术储备。 | +| 6 | **团队文档管理与周报** | 完成 | 2025-11-09,按时归档了本周的接口文档、需求规格书等产出,并完成了个人周总结和小组第七周周总结的起草工作。 | + +## 小结 + +1. **开发正式启动**:本周是项目从规划转向实践的关键一周,尝试搭建了统一的后端开发环境。 +2. **文档工作收尾**:按时完成了需求规格书V1.0中负责章节的撰写,并完成了本周的团队文档归档和总结工作。 +3. **技术储备方面**:已深入学习了JWT认证等模块开发所需的核心技术,为下周根据接口文档编码任务做准备中。 \ No newline at end of file diff --git a/doc/process/weekly/week-08/group/meeting-minutes-08.md b/doc/process/weekly/week-08/group/meeting-minutes-08.md new file mode 100644 index 0000000..bc9bd18 --- /dev/null +++ b/doc/process/weekly/week-08/group/meeting-minutes-08.md @@ -0,0 +1,141 @@ +# 小组会议纪要-第8周 + +## 会议记录概要 +**团队名称**: 3班-葫芦娃救bug +**指导老师**: 李友焕 +**主 持 人**: 项目经理 +**记录人员**: 王利蓉 +**会议主题**: 迭代开发计划第一稿评审、任务分工细化、数据库设计文档定稿 +**会议地点**: 线上会议 +**会议时间**: 2025-11-16 15:00-16:00 +**记录时间**: 2025-11-16 16:30 +**参与人员**: 全体成员 + +## 会议内容 + +### 1. 迭代开发计划第一稿评审 +- **计划完整性确认**: + - 对α版本(第9-12周)和β版本(第13-14周)的时间安排进行评审 + - 确认功能优先级划分合理,核心功能安排在α版本开发 + - 验证迭代任务分解的可行性和完整性 + +- **关键功能讨论**: + - **高优先级功能**:管理数据库项目、自然语言查询与分析、执行数据变更操作等 + - **中优先级功能**:用户登录、管理个人账户、管理业务术语库等 + - **低优先级功能**:查看公告、申请解封、审核用户申请等 + +- **技术实现确认**: + - AI代理模块分工明确:伊木然负责DDL部分,王利蓉负责文本到SQL(DQL)部分 + - 前后端开发任务分配合理,确保各模块有明确负责人 + - 数据库搭建和查询执行由梁峻耀主要负责 + +### 2. 任务分工细化与优化 +- **核心模块负责人最终确认**: + - **用户认证模块**:李果霖(前端)、伊木然(后端) + - **AI代理模块**:伊木然(DDL)、王利蓉(文本到SQL-DQL) + - **会话管理**:李果霖、李文韬(前后端协作) + - **业务术语库**:伊木然、李文韬 + - **数据库管理**:梁峻耀(搭建、连接、查询执行) + - **界面展示**:李果霖、李文韬 + +- **交付标准明确**: + - 每个任务需提供设计文档、前端界面、后端功能代码 + - AI相关模块需提供Python代码和AI调用逻辑 + - 数据库相关任务需提供SQL脚本和连接方案 + +### 3. 数据库设计文档最终确认 +- **设计成果确认**: + - 15张核心表结构设计通过最终评审 + - 用户活动表、违规操作日志表等关键表优化完成 + - 审计机制设计完善,确保操作可追溯 + +- **技术实现进展**: + - SQL脚本基础测试通过,支持CRUD操作 + - 字符集统一采用utf8mb4 + - 数据库设计文档正式定稿提交 + +### 4. 开发环境与基础架构同步 +- **后端部署进展**: + - Docker容器化部署完成,实现环境标准化 + - 持续集成流程配置完成 + - 服务健康检查机制建立 + +- **接口联调状态**: + - 前后端基础通信验证通过 + - 用户认证、数据查询等核心接口测试完成 + - 建立接口文档更新维护机制 + +### 5. 第九周工作计划制定 +- **核心开发目标**: + - 正式启动α版本各功能模块编码工作 + - 完成迭代开发计划第二稿修订和提交 + - 开展UML图表设计工作 + +- **重点任务安排**: + - **前端**:用户登录界面、主工作区布局实现 + - **后端**:用户认证模块完善、API接口开发 + - **AI模块**:启动QLoRA模型实验 + - **数据库**:基于设计文档推进实现 + +- **重要时间节点**: + - 11月19日:核心功能开发中期检查 + - 11月21日:迭代开发计划第二稿提交(晚10:00截止) + +## 后续任务安排 + +### (1)项目开发重点: +- **功能开发**:按迭代计划分工推进各模块编码 +- **接口协调**:前后端密切配合,确保接口一致性 +- **质量保证**:建立代码审查和测试流程 +- **进度跟踪**:实施每日开发进度同步机制 + +### (2)具体分工确认: +- 会议记录:王利蓉(本周) +- 会议主持:项目经理 +- 文档管理:李果霖 +- 周计划与周总结:王利蓉 +- 进度管理:李果霖 +- 日常考勤:王利蓉 +- 前端开发:李果霖、李文韬 +- 后端开发:梁峻耀、王利蓉、伊木然 +- AI模型开发:伊木然、王利蓉 +- 测试验证:全体成员 + +### (3)会议安排: +- 下周例会时间:11月23日 星期日晚上7:00-8:00 +- 技术讨论:根据开发需要随时组织 +- 进度同步:建议每日简短会议(15分钟) + +## 问题总结 + +### 已解决问题: +- 迭代开发计划第一稿完成评审并通过验收 +- 各功能模块任务分工明确到具体责任人 +- 数据库设计文档最终定稿并提交 +- 开发环境配置完成,为编码工作做好准备 + +### 待解决问题: +- UML图表设计工作需要实际启动 +- AI模型实验需按计划在本周正式开展 +- 各模块开发进度跟踪机制需要强化 +- 代码规范性检查流程需要细化 + +## 小组协作情况总结 +**协作情况**: 本周团队在迭代计划评审和任务分工方面协作效果显著,各成员积极参与讨论,明确了个人职责范围。技术方案讨论充分,为α版本开发奠定了坚实基础。 + +## 一周纪律情况总结 +**纪律情况**: 会议出席整齐,讨论过程高效有序。各成员在计划制定和环境准备方面表现出良好的执行力,为后续开发工作创造了有利条件。 + +## 备注 +- 第九周是α版本编码实现的关键起步阶段,需要各模块负责人加强进度管理 +- 建议建立每日进度同步机制,及时发现和解决开发障碍 +- 代码质量需要从开发初期严格把控,为后续代码互评做准备 +- UML设计工作需尽快分配责任人并启动 + +--- +## 【本周重点事项提醒】 +1. 迭代开发计划第二稿需在11月21日晚10点前完成修订并提交 +2. 各开发模块需在11月19日完成中期进度检查 +3. AI模型实验工作本周必须按计划正式启动 +4. 代码规范性自查需在本周内完成首轮检查 +5. 前后端开发需保持密切沟通,确保接口一致性 diff --git a/doc/process/weekly/week-08/group/weekly-plan-08.md b/doc/process/weekly/week-08/group/weekly-plan-08.md new file mode 100644 index 0000000..37da597 --- /dev/null +++ b/doc/process/weekly/week-08/group/weekly-plan-08.md @@ -0,0 +1,27 @@ +# 小组周计划-第8周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-10 + +**结束时间:** 2025-11-16 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人** | **情况说明** | +| -------- | ----------------------------- | ------------------------------------- | ------------------------------------------------------------ | +| 1 | **UML图表设计** | 全体成员 | 绘制系统总体用例图,以及主要业务功能的活动图、顺序图和类图。 | +| 2 | **制定迭代开发计划(Alpha)** | 全体成员 | **【本周重点】**联系指导老师,明确Alpha和Beta版本的核心功能范围,完成第一稿迭代计划并提交至平台(周日晚10:00前)。 | +| 3 | **V1.0 接口文档定稿** | 前后端开发人员 | 基于第7周的定义工作,完成V1.0接口文档的最终评审与定稿,为本周开发提供依据。 | +| 4 | **V1.0 (Alpha) 前端功能开发** | 前端开发人员 (李文韬, 李果霖) | **【本周重点】**正式启动Alpha版本开发。依据V1.0接口文档(任务3),重构或优化第7周项目框架,对接API,实现用户、项目管理、对话等核心功能。 | +| 5 | **V1.0 (Alpha)后端功能开发** | 后端开发人员 (梁峻耀, 王利蓉, 伊木然) | **【本周重点】**正式启动Alpha版本开发。基于第7周搭建的环境,实现V1.0接口文档中定义的用户、项目、消息模块的核心API。 | +| 6 | **数据库设计文档定稿** | 梁峻耀, 王利蓉 | 根据第7周小班课的评审反馈,修改并提交最终版数据库设计文档(周日晚10:00前)。 | +| 7 | **AI模型推进** | AI负责人员 | 从调研转向实践。搭建QLoRA代码框架,选用bird数据集作为训练集,为首次实验做准备。 | + +## 小结 + +1. 本周是Alpha版本开发的**正式启动周**。核心目标是全员进入编码阶段(任务4、5),围绕V1.0(Alpha版)核心功能实现前后端联调。 +2. **规划与文档先行**:需优先完成**UML图表设计(任务1)**、**迭代计划制定(任务2)** 与**V1.0接口文档定稿(任务3)**,为开发工作提供清晰指引。 +3. **全面推进**:在前期开发的同时,AI模型微调工作需从理论转向实践(任务7);同时,必须完成数据库设计(任务6),确保各项任务按时交付。 diff --git a/doc/process/weekly/week-08/group/weekly-summary-08.md b/doc/process/weekly/week-08/group/weekly-summary-08.md new file mode 100644 index 0000000..626c544 --- /dev/null +++ b/doc/process/weekly/week-08/group/weekly-summary-08.md @@ -0,0 +1,27 @@ +# 小组周总结-第8周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-10 + +**结束时间:** 2025-11-16 + +### 本周任务完成情况 + +| 序号 | 内容总结 | 是否完成 | 情况说明 | +| ---- | ----------------------------- | -------- | ------------------------------------------------------------ | +| 1 | **UML图表设计** | 进行中 | 经过课上系统性的学习活动图和状态图之后,团队决定学习过用例图,顺序图和类图之后,再绘制系统总体用例图,以及主要业务功能的活动图、顺序图和类图。 | +| 2 | **制定迭代开发计划(Alpha)** | 完成 | 【**本周重点**】团队联系指导老师,明确Alpha和Beta版本的核心功能,完成第一稿迭代计划的撰写,为后续开发提供明确方向 | +| 3 | **V1.0 接口文档定稿** | 完成 | 初步定稿成功。后端已完成用户与项目管理模块的核心API开发,并通过Postman完成了接口功能性测试,验证了业务逻辑的正确性。但由于前端界面尚在构建中以及局域网环境配置问题,完整的前后端交互联调(Integration Testing)将推迟至之后进行进行。 | +| 4 | **V1.0 (Alpha) 前端功能开发** | 进行中 | 【**本周重点**】重构前端结构,将API接口模块独立,以便优化和逻辑复用,为后续V2.0接口文档提供拓展能力;后续将进行前端界面和逻辑的相应编写 | +| 5 | **V1.0 (Alpha)后端功能开发** | 进行中 | 【**本周重点**】重构后端结构,同步编写API文档和数据库搭建,为前后端联调奠定基础 | +| 6 | **数据库设计文档定稿** | 完成 | 根据第7周小班课的评审反馈,修改数据库设计文档,组内开会评审文档后,本周日晚10:00前提交最终版的数据库设计文档 | +| 7 | **AI模型推进** | 未完成 | 迫于考试压力,组内将AI模型微调的任务推迟至下周,争取一周内完成第一次微调 | + +### 小结 + +1. **Alpha版本开发正式启动**:本周团队进入Alpha版本开发阶段。前端和后端开发工作均在进行中,双方都优先进行了代码结构重构(如前端分离API模块、后端重构结构),以便于后续的功能复用和拓展,并为前后端联调奠定基础。 +2. **规划与文档成果明确**:本周成功完成了**迭代修改开发计划(Alpha 版)的制定**,并联系了老师明确的功能范围。**数据库设计文档**也已根据评审反馈并定稿提交。 +3. **进行中及延期的任务**:**UML图表设计**任务目前处于进行中,而**V1.0接口文档**虽然已经初步定稿,但于开发时可能还有优化与改动。其中,UML图表设计将在团队学习完成相应UML图后继续。最后,受考试压力影响,**AI模型**任务推进本周未能完成,已推迟至下周执行。 \ No newline at end of file diff --git a/doc/process/weekly/week-08/members/liangjunyao-weekly-plan-08.md b/doc/process/weekly/week-08/members/liangjunyao-weekly-plan-08.md new file mode 100644 index 0000000..8d9ae6f --- /dev/null +++ b/doc/process/weekly/week-08/members/liangjunyao-weekly-plan-08.md @@ -0,0 +1,30 @@ +# 个人周计划-第8周 + +## 姓名与起止时间 +**姓  名**: 梁峻耀 +**团队名称**: 3班-葫芦娃救bug +**开始时间**: 2025-11-10 +**结束时间**: 2025-11-16 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | --------------------------- | -------------- | ------------------------------------------------------------ | +| 1 | 数据库设计文档最终版编写 | 梁峻耀、王利蓉 | **[本周重点,周三前完成]** 根据第7周小班课评审反馈,完善15张表的详细设计文档,包括ER图更新、字段说明、索引设计和表关系说明。确保文档内容完整、描述清晰,满足评审要求。 | +| 2 | 数据库ORM模型实现 | 个人 | **[本周重点]** 基于最终版数据库设计文档,使用SQLAlchemy实现所有表的ORM模型代码,完成模型间的关联关系定义,编写基础CRUD操作代码,并添加必要的数据验证逻辑。 | +| 3 | 华为云服务器环境部署 | 个人 | 将本地Docker配置的环境(FastAPI+PostgreSQL+Redis)完整部署到华为云ECS服务器,完成域名解析、SSL证书配置,确保服务可通过公网稳定访问 | +| 3 | V1.0 (Alpha)后端核心API开发 | 后端开发人员 | 实现V1.0接口文档中定义的用户管理、项目管理和对话模块的核心API,确保接口符合规范,编写单元测试覆盖主要业务逻辑。与前端人员保持沟通,确保接口联调顺畅。 | +| 4 | 前后端联调测试 | 前端开发人员 | 配合前端开发人员进行API联调测试,解决接口调用过程中的数据格式、权限认证和错误处理问题,记录并修复发现的bug。 | +| 5 | UML图表设计 | 全体成员 | 参与绘制系统类图和关键业务流程的顺序图,重点负责后端相关模块的UML设计,确保图表准确反映系统架构和关键交互流程。 | +| 6 | Alpha版本迭代计划确认 | 全体成员 | 参与Alpha版本迭代计划的最终确认,明确核心功能范围和开发优先级,确保个人任务与团队整体规划保持一致。 | + + + +## 小结 +**时间规划**:本周核心任务是数据库文档(任务1)和ORM实现(任务2),需在周三前完成文档定稿,为后续开发奠定基础。后半周重点投入服务器环境配置(任务3)、API开发(任务4)和联调测试(任务5)。 + +**关键路径**:数据库设计文档是ORM实现的前提,必须优先确保按时完成;ORM模型的正确实现直接影响API开发质量和进度,需特别注意数据关系和验证逻辑;华为云环境部署(任务3)是团队协作的基础,需优先完成并验证可用性。 + +**风险控制**:文档评审可能需要多轮修改,应预留缓冲时间;联调测试中可能暴露接口设计问题,需保持与前端开发人员的高频沟通。如遇技术难点,应及时在团队内寻求帮助,避免阻塞整体进度;服务器部署可能遇到网络配置或权限问题,需提前准备解决方案;。 + +**技术准备**:提前熟悉SQLAlchemy的高级特性,特别是关系映射和事务管理;准备好测试数据,用于验证ORM模型和API功能;确保本地开发环境与团队规范一致,避免环境差异导致的问题。 \ No newline at end of file diff --git a/doc/process/weekly/week-08/members/liangjunyao-weekly-summary-08.md b/doc/process/weekly/week-08/members/liangjunyao-weekly-summary-08.md new file mode 100644 index 0000000..56c01a7 --- /dev/null +++ b/doc/process/weekly/week-08/members/liangjunyao-weekly-summary-08.md @@ -0,0 +1,28 @@ +# 个人周总结-第8周 + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-11-10 +**结束时间**:2025-11-16 + +## 本周任务完成情况 + +| 序号 | 计划内容 | 是否完成 | 情况说明 | +| ---- | --------------------------- | -------- | ------------------------------------------------------------ | +| 1 | 数据库设计文档最终版编写 | 完成 | 根据第7周评审反馈,完成15张表的详细设计文档,包括ER图更新、字段说明、索引设计和表关系说明,文档已通过组内评审并按时提交。 | +| 2 | 数据库ORM模型实现 | 完成 | 基于最终版数据库设计文档,使用SQLAlchemy完成所有表的ORM模型代码实现,包括模型间关联关系定义、基础CRUD操作及数据验证逻辑。 | +| 3 | 华为云服务器环境部署 | 未完成 | 华为代金卷还未发下来,这周只完善了docker-compose,让它可以在Windows下开发,在Linux环境下部署。 | +| 4 | V1.0 (Alpha)后端核心API开发 | 进行中 | 已完成用户管理、项目管理模块的核心API开发,对话模块接口正在实现中,已编写部分单元测试,与前端保持沟通以支持联调。 | +| 5 | 前后端联调测试 | 未完成 | 只通过API初步文档确认了格式问题,但由于radmin组成局域网进行联调有点问题,目前后端用postman测试。 | +| 6 | UML图表设计 | 进行中 | 参与系统类图与关键业务流程顺序图的绘制,重点完成后端相关模块的UML设计,在课上学习了顺序图、类图后进一步优化UML,待团队进一步评审完善。 | +| 7 | Alpha版本迭代计划确认 | 完成 | 参与团队Alpha版本迭代计划的最终确认会议,明确核心功能范围与开发优先级,个人任务与团队规划保持一致。 | + +## 小结 + +**任务推进与成果**:本周重点完成了数据库设计文档定稿与ORM模型实现,为后端开发奠定了坚实基础。后端核心API开发按计划推进,已实现用户与项目管理模块的主要接口。受限于华为云代金券发放进度,服务器部署任务暂未完成,但已完善了docker-compose配置,确保在Windows开发与Linux部署环境下的兼容性。 + +**协作与沟通**:积极参与团队UML设计与迭代计划确认,确保个人工作与整体项目方向一致。与前端人员保持密切沟通,通过API文档初步确认了数据格式规范。由于radmin局域网联调存在技术问题,目前采用postman进行接口测试,同时寻求更稳定的联调解决方案。 + +**技术学习与优化**:在UML设计过程中,结合课上学习的顺序图、类图知识,对后端相关模块的UML图表进行了进一步优化,确保图表准确反映系统架构。 + +**风险控制与应对**:面对服务器部署延迟的情况,通过完善本地开发环境配置确保开发工作不受影响。在ORM实现与API开发中注重代码规范与数据验证,为后续功能扩展与维护提供良好基础。后续将重点推进API开发,并在服务器资源到位后尽快完成环境部署,确保Alpha版本核心功能按时交付。 \ No newline at end of file diff --git a/doc/process/weekly/week-08/members/liguolin-weekly-plan-08.md b/doc/process/weekly/week-08/members/liguolin-weekly-plan-08.md new file mode 100644 index 0000000..09ce2a4 --- /dev/null +++ b/doc/process/weekly/week-08/members/liguolin-weekly-plan-08.md @@ -0,0 +1,28 @@ +# 个人周计划-第8周 + +## 姓名与起止时间 + +**姓名**:李果霖 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-11-10 + +**结束时间**:2025-11-16 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | ----------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **V1.0 (Alpha) 前端功能开发** | 前端开发人员 | **【本周重点】**正式启动Alpha版本开发。基于第7周搭建的React框架,依据V1.0接口文档(任务3),开始搭建API,实现用户、项目管理、对话等核心功能。 | +| 2 | **UML图表设计** | 全体成员 | 根据团队计划,设计系统总体用例图,并重点负责相关活动图、顺序图和类图。 | +| 3 | **V1.0 接口文档定稿** | 前端开发人员 | 解决第7周“进行中”的问题。作为前端代表,与前端同事共同完成接口的最终评审与定稿,为本周开发(任务1)提供依据。 | +| 4 | **制定迭代开发计划(Alpha)** | 全体成员 | **【本周重点】**与团队成员共同联系指导老师,明确Alpha版本的功能范围,并完成第一稿迭代计划的制定。 | +| 5 | **支持AI模型推进** | AI负责人员 | 延续第7周工作,关注QLoRA驱动代码框架的实践进展,在前置任务方面提供支持。 | +| 6 | **整理个人笔记与周报** | 个人 | 整理本周在UML设计、接口定义和Alpha开发过程中的笔记,回顾任务完成情况,并撰写个人周总结。 | + +## 小结 + +1. 本周是Alpha版本开发的**正式启动周**。我将作为前端主力,与李文韬共同启动V1.0(Alpha)版本的功能开发(任务1),**核心工作是将第7周搭建的框架与本周定稿的V1.0接口(任务3)进行对接**,实现核心功能的联调。 +2. **规划与文档先行**:在开发的同时,我将积极参与**UML图表设计(任务2)和迭代开发计划的制定(任务4)**,确保开发工作有清晰的指引。 +3. **技术跟进**:我也将继续关注AI模型调优的进展(任务5),确保前端在设计时同步考虑后续模型集成所需的接口调度。 diff --git a/doc/process/weekly/week-08/members/liguolin-weekly-summary-08.md b/doc/process/weekly/week-08/members/liguolin-weekly-summary-08.md new file mode 100644 index 0000000..6ebe9d6 --- /dev/null +++ b/doc/process/weekly/week-08/members/liguolin-weekly-summary-08.md @@ -0,0 +1,28 @@ +# 个人周总结-第8周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-11-10 + +结束时间:2025-11-16 + +## 本周任务完成情况 + +| **序号** | **计划内容** | 完成情况 | **情况说明** | +| -------- | ----------------------------- | ---------- | ------------------------------------------------------------ | +| 1 | **V1.0 (Alpha) 前端功能开发** | **进行中** | 作为前端成员,本周重点重构了接口结构,将API接口模块独立,以便优化和逻辑复用,为V2.0接口提供扩展能力。后续将进行接口和逻辑的编写。 | +| 2 | **UML图表设计** | **进行中** | 参与了团队讨论。团队决定在系统性学习活动图、状态图、示例图、顺序图和类图之后,再统一迭代不同的系统图表。 | +| 3 | **V1.0 接口文档定稿** | **进行中** | 已完成用户管理、项目管理模块的核心API开发。已编写部分单元测试,与后端成员保持沟通以支持联调。 | +| 4 | **制定迭代开发计划(Alpha)** | **完成** | 参与完成了团队与指导教师的联系,明确了 Alpha 和 Beta 版本的核心功能,并共同完成了第一稿迭代计划的撰写。 | +| 5 | **支持AI模型推进** | **未完成** | 原计划关注 QLoRA 框架进展并提供支持。由于团队考试压力,AI 模型任务已推迟至下周。 | +| 6 | **整理个人笔记与周报** | **完成** | 整理了本周在UML设计、接口定义和改进重构过程中的笔记,并撰写了本总结。 | + +## 小结 + +1. **Alpha开发启动与重构重构**:本周是Alpha版本开发的正式启动周。作为前端主力,我没有立即开始实现具体的页面功能,而是优先**重构了前端代码结构**,特别是**API接口模块独立化**。这项工作虽然超出了原计划的“对接API”,但为后续的功能复用和V2.0拓展奠定了良好的基础。 +2. **规划文档的进展**:本周我参与的规划与文档工作喜忧参半。我们成功联系了指导学习老师并按第一时完成了**迭代开发稿(已完成)**。另外,**UML图表设计**和**V1.0接口文档**均处于“进行中”状态。UML设计因团队决定先再相关而推迟;接口文档在初步编写好后,需等待前进一步开发才能联调定稿。 +3. **延期任务**:原计划支持的**AI模型推进工作**,因团队考试压力已确认**推迟至下周**。 \ No newline at end of file diff --git a/doc/process/weekly/week-08/members/liwentao-weekly-plan-08.md b/doc/process/weekly/week-08/members/liwentao-weekly-plan-08.md new file mode 100644 index 0000000..8412e5a --- /dev/null +++ b/doc/process/weekly/week-08/members/liwentao-weekly-plan-08.md @@ -0,0 +1,32 @@ +# 个人周计划-第8周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-11-10 + +**结束时间**:2025-11-16 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ----------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | V1.0 (Alpha) 核心功能前端实现 | 李果霖 | **[本周重点]** 本周正式进入Alpha版编码。需与李果霖配合,根据定稿的V1.0接口文档,着手实现用户认证(登录/注册)、项目管理和核心对话界面的功能,并开始进行前后端接口联调。 | +| 2 | V1.0 接口文档评审与定稿后端 | 后端开发人员 | 参与V1.0接口文档的最终评审。重点是从前端实现角度出发,确认请求/响应的数据结构是否合理、完整,确保文档能作为本周开发的明确依据。 | +| 3 | 参与制定 Alpha 迭代开发计划 | 全体成员 | **[本周重点]** 参与团队讨论,特别是与指导老师沟通后,从前端开发工作量的角度评估功能优先级,协助团队明确Alpha版本的核心功能范围。 | +| 4 | 参与 UML 图表设计 | 全体成员 | 参与团队UML图表的设计讨论,重点关注与前端交互逻辑相关的活动图和顺序图,确保图表清晰反映用户操作流程。 | +| 5 | 一周总结 | 个人 | 2025-11-16 整理本周开发和文档产出,复盘开发中遇到的问题,参与制定小组下周计划,并结合小组计划制定个人计划。 | + + + +## 小结 + +1. **核心任务:** 本周的核心是 Alpha 版的编码启动。我将与李果霖协作,基于 V1.0 接口文档,前端核心模块(如用户、项目、对话)的开发与联调工作。 +2. **开发前提:** 开发工作启动的前提是 V1.0 接口文档的定稿。我将从前端角度参与最终评审,确保接口定义的清晰可用,避免后续联调时的阻碍。 +3. **团队规划:** 作为开发人员,我将深入参与团队的迭代计划制定和 UML 设计。我的主要职责是负责评估前端工作量和梳理前端相关的交互流程。 +4. **常规总结:** 计划在 2025-11-16 完成本周的开发总结和文档整理,并为下一周的开发冲刺做好计划。 + + \ No newline at end of file diff --git a/doc/process/weekly/week-08/members/liwentao-weekly-summary-08.md b/doc/process/weekly/week-08/members/liwentao-weekly-summary-08.md new file mode 100644 index 0000000..8fa1c07 --- /dev/null +++ b/doc/process/weekly/week-08/members/liwentao-weekly-summary-08.md @@ -0,0 +1,45 @@ + +# 个人周总结-第8周 + +## 姓名和起止时间 + +**姓  名:** 李文韬 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-10 +**结束时间:** 2025-11-16 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | ----------------------------- | -------- | ------------------------------------------------------------ | +| 1 | V1.0 (Alpha) 核心功能前端开发 | 进行中 | **[本周重点]** 与李果霖协作,重构了前端结构,将API接口模块独立,以便优化和逻辑复用,为后续V2.0接口文档提供拓展能力。原计划的界面实现和联调工作将推至下周。 | +| 2 | V1.0 接口文档评审与定稿 | 进行中 | 参与了V1.0接口文档的初步编写。目前文档尚未定稿,正等待后端搭建数据库以及前端搭建界面和相应跳转逻辑后,进行联调测试。 | +| 3 | 参与制定 Alpha 迭代开发计划 | 完成 | **[本周重点]** 积极参与团队讨论,联系了指导老师,明确了Alpha和Beta版本的核心功能,完成了第一稿迭代计划的撰写。 | +| 4 | 参与 UML 图表设计 | 进行中 | 参与了初步讨论。团队在课上系统学习后,决定在学习完用例图、顺序图和类图之后,再进行系统性的绘制工作。 | +| 5 | 一周总结 | 完成 | 整理了本周的开发产出和文档,参与制定了小组下周计划,并完成了本份周总结。 | + + + +## 对团队工作的建议 + + + +**聚焦接口定稿**:本周V1.0接口文档仍在进行中,建议下周(第9周)必须优先完成接口文档定稿,尤其是核心的用户和项目模块,以便前后端能立即开始对接。 + +**加快功能编码**:本周前后端都完成了结构重构。下周应全面进入功能编码阶段,前端必须基于分离出的API模块,迅速开始编写界面和业务逻辑,不能只停留在框架层面。 + +**跟进AI微调**:AI模型微调任务本周因期中考试推迟。建议AI负责人下周必须启动此项工作,争取一周内完成首次微调,以评估效果并为后续迭代做准备。 + +## 小结 + + + + + +**核心任务**:本周的个人核心任务是启动Alpha版前端开发。工作重点放在了与李果霖协作重构前端结构,特别是分离了API模块,为后续开发和联调奠定了基础。 + +**规划与文档**:我积极参与了Alpha迭代开发计划的制定,并与团队完成了定稿。同时,参与了V1.0接口文档的初步编写,目前仍在进行中。 + +**任务调整**:原计划的UML图表设计,团队决定在进一步学习后再执行。由于V1.0接口文档未定稿,原计划本周开始的前后端联调工作也相应顺延至下周。 + +**常规总结**:2025-11-16 按时完成了本周的开发总结和下周计划的制定。 diff --git a/doc/process/weekly/week-08/members/wanglirong-weekly-plan-08.md b/doc/process/weekly/week-08/members/wanglirong-weekly-plan-08.md new file mode 100644 index 0000000..41480c4 --- /dev/null +++ b/doc/process/weekly/week-08/members/wanglirong-weekly-plan-08.md @@ -0,0 +1,27 @@ +# 个人周计划-第8周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-10 +**结束时间:** 2025-11-16 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | UML图表设计 | 全体成员 | 参与绘制系统总体用例图,以及主要业务功能的活动图、顺序图和类图 | +| 2 | 制定迭代开发计划(Alpha) | 全体成员 | 参与联系指导老师,明确Alpha版本功能范围,协助完成第一稿迭代计划制定 | +| 3 | V1.0接口文档定稿 | 前后端开发人员 | 参与V1.0接口文档的最终评审与定稿,确保接口规范准确完整 | +| 4 | V1.0 (Alpha)后端功能开发 | 梁峻耀、伊木然 | **【本周重点】**基于第7周环境,实现V1.0接口文档中定义的用户、项目、消息模块的核心API | +| 5 | 数据库设计文档定稿 | 梁峻耀 | 根据第7周小班课评审反馈,修改并提交最终版数据库设计文档 | + +## 小结 + +1. **开发重点:** 本周正式启动Alpha版本后端开发,重点实现用户、项目、消息模块的核心API功能; +2. **文档完善:** 需要完成UML图表设计、迭代计划和数据库设计文档的定稿工作; +3. **技术实践:** 从理论学习转向实际编码,需要快速掌握FastAPI实际开发技巧; +4. **希望获得的帮助:** 希望有关于FastAPI高级应用和数据库优化方面的实战指导,提升开发效率; +5. **团队协作:** 加强与前端同学的沟通协调,确保接口定义准确,为后续联调打好基础。 + diff --git a/doc/process/weekly/week-08/members/wanglirong-weekly-summary-08.md b/doc/process/weekly/week-08/members/wanglirong-weekly-summary-08.md new file mode 100644 index 0000000..fffbbac --- /dev/null +++ b/doc/process/weekly/week-08/members/wanglirong-weekly-summary-08.md @@ -0,0 +1,44 @@ +# 个人周总结-第8周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-10 +**结束时间:** 2025-11-16 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | UML图表设计 | 进行中 | 参与系统总体用例图和主要业务功能图表设计的初步讨论,图表绘制工作已开始但尚未完成 | +| 2 | 制定迭代开发计划(Alpha) | 完成 | 参与团队讨论,协助完成迭代开发计划第一稿的制定和提交 | +| 3 | V1.0接口文档定稿 | 进行中 | 参与前后端接口文档的初步制定,后续还需细化完善 | +| 4 | V1.0 (Alpha)后端功能开发 | 部分完成 | 基于第7周环境,初步实现了用户、项目模块的核心API,部分功能仍需细化和整改 | +| 5 | 数据库设计文档定稿 | 完成 | 与梁峻耀协作,根据评审反馈完成数据库设计文档的修改并提交最终版 | + +## 对团队工作的建议 + +1. **进度同步机制**:建议建立更频繁的开发进度同步机制,确保各模块开发协调推进; +2. **技术难点预研**:针对复杂功能模块,建议提前进行技术预研,避免开发过程中出现重大技术障碍; +3. **代码规范统一**:建议加强代码规范性检查,为后续代码互评工作做好准备; +4. **任务分解细化**:建议将开发任务进一步细化分解,明确各子任务的责任人和完成时间。 + +## 小结 + +1. **文档工作成果**:顺利完成了迭代开发计划第一稿、数据库设计文档等重要文档的定稿和提交工作; +2. **开发工作启动**:正式启动了Alpha版本后端开发工作,初步实现了核心API功能; +3. **技术实践积累**:通过实际编码工作,积累了FastAPI开发经验,提升了技术实践能力; +4. **团队协作顺畅**:在数据库设计和接口定义等工作中与团队成员协作良好; +5. **待完善工作**:UML图表设计工作需要加快推进,部分后端功能仍需进一步细化和完善; +6. **需要技术支持**:希望在FastAPI高级特性和数据库性能优化方面获得更多实战指导。 + + +--- + +## 【注】 + +1. **技术指导需求**:希望在FastAPI高级应用和数据库性能优化方面获得更多实战指导; +2. **进度同步建议**:建议团队建立更频繁的开发进度同步机制; +3. **文档提交确认**:本周个人总结已按时提交给负责人,相关文档已上传至代码托管平台; +4. **重点任务提醒**:UML图表设计和部分后端功能细化工作需要在下周优先完成。 diff --git a/doc/process/weekly/week-08/members/yimuran-weekly-plan-08.md b/doc/process/weekly/week-08/members/yimuran-weekly-plan-08.md new file mode 100644 index 0000000..143d182 --- /dev/null +++ b/doc/process/weekly/week-08/members/yimuran-weekly-plan-08.md @@ -0,0 +1,25 @@ +# 个人周计划-第8周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-10 + + **结束时间:** 2025-11-16 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人** | **情况说明** | +| -------- | ----------------------------- | ---------- | ------------------------------------------------------------ | +| 1 | UML图表设计 | 全体 | 参与UML图表设计,重点负责“用户管理”和“项目管理”相关的活动图、顺序图和类图的绘制。 | +| 2 | **制定迭代开发计划(Alpha)** | 全体 | **【本周重点】** 参与团队会议,联系指导老师,讨论并明确Alpha版本中“用户与项目管理”模块的具体功能范围(例如,必须实现注册、登录、创建项目)。 | +| 3 | **V1.0 接口文档定稿** | 全体 | 作为“用户与项目管理”模块负责人,与前后端同学最终评审并锁定V1.0的接口文档(如 `/register`, `/login`, `/projects` 等),确保格式统一。 | +| 4 | **V1.0 (Alpha)后端功能开发** | 个人 | **【本周重点】** 正式启动Alpha版本开发。基于V1.0接口文档(任务3)和第7周搭建的环境,尝试实现“用户与项目管理”模块的核心API(实现用户注册、登录、JWT令牌生成)。 | +| 5 | **数据库设计文档评审** | 全体 | 参与梁峻耀、王利蓉主导的数据库设计文档最终评审,确保 `users` 表和 `projects` 表结构满足模块功能需求。 | + +## 小结 + +1. 本周是Alpha版本开发的**正式启动周**。我的核心目标是启动“用户与项目管理”模块的实际编码工作(任务4)。 +2. **文档定稿是前提**:必须优先与团队(尤其是前端)敲定V1.0的接口文档(任务3),这是本周开发工作的最重要依据。 +3. **积极参与规划**:在编码的同时,需要积极参与UML图表设计(任务1)和Alpha迭代计划的制定(任务2),确保我负责的模块设计符合团队的整体蓝图。 \ No newline at end of file diff --git a/doc/process/weekly/week-08/members/yimuran-weekly-summary-08.md b/doc/process/weekly/week-08/members/yimuran-weekly-summary-08.md new file mode 100644 index 0000000..803a0ed --- /dev/null +++ b/doc/process/weekly/week-08/members/yimuran-weekly-summary-08.md @@ -0,0 +1,30 @@ +# 个人周总结-第8周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-10 + +**结束时间:** 2025-11-16 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **是否完成** | **情况说明** | +| -------- | ----------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **UML图表设计** | **进行中** | 参与了团队讨论。团队决定在系统性学习活动图、状态图、顺序图和类图之后,再统一绘制“用户管理”和“项目管理”等主要业务功能的UML图表。 | +| 2 | **制定迭代开发计划(Alpha)** | **完成** | **[本周重点]** 参与了团队与指导老师的联系,明确了Alpha版本中“用户与项目管理”模块必须实现注册、登录、创建项目等核心功能,并共同完成了第一稿迭代计划的撰写。 | +| 3 | **V1.0 接口文档定稿** | **进行中** | 已完成用户管理、项目管理模块的核心API开发。已编写部分单元测试,与后端成员保持沟通以支持联调。 | +| 4 | **数据库设计文档评审** | **完成** | 参与了数据库设计文档的最终评审,确认了 `users` 表和 `projects` 表结构满足模块功能需求,小组已按时提交最终版文档。 | +| 5 | **文档管理与周报** | **完成** | 作为文档管理员,跟进了本周《迭代开发计划》、《数据库设计文档》的定稿与提交。整理了本周的开发笔记并撰写了本总结。 | +| 6 | **V1.0 (Alpha)后端功能开发** | 进行中 | 【**本周重点**】重构后端结构,同步编写API文档和数据库搭建,为前后端联调奠定基础 | + +## 小结 + +1. **规划与文档定稿**:本周的核心产出是文档。我重点参与并完成了《迭代开发计划》和《数据库设计文档》的制定与定稿,这是后续开发的重要依据。 + +2. **进行中任务**:UML图表设计任务因团队决定先统一学习再实践而推迟,将在下周继续跟进。 + + \ No newline at end of file diff --git a/doc/process/weekly/week-09/group/meeting-minutes-09.md b/doc/process/weekly/week-09/group/meeting-minutes-09.md new file mode 100644 index 0000000..928075a --- /dev/null +++ b/doc/process/weekly/week-09/group/meeting-minutes-09.md @@ -0,0 +1,115 @@ +# 小组会议纪要-第9周 + +## 会议记录概要 + +**团队名称**: 3班-葫芦娃救bug + +**指导老师**: 李友焕 + +**主 持 人**: 项目经理,李文韬 + +**记录人员**: 伊木然 + +**会议主题**: α版本开发启动与初期进度同步、迭代计划第二稿评审 + +**会议地点**: 线上会议 + +**会议时间**: 2025-11-23 19:00-20:00 + +**记录时间**: 2025-11-23 20:30 + +**参与人员**: 全体成员 + +## 会议内容 + +### 1. 迭代开发计划第二稿评审与提交 + +- **计划修订确认**: + - 结合第8周老师的反馈,对迭代开发计划进行了第二轮修订。 + - 重点完善了AI模块和文本到SQL功能的详细排期,确保时间节点与实际开发进度匹配。 + - 确认迭代开发计划第二稿已按时于周五(11月21日)晚10:00前提交至头歌平台。 + +### 2. α版本开发进度汇报与同步 + +各模块负责人汇报了本周开发进展,整体进入编码实战阶段: + +- **AI模型模块(执行人:李果霖AI负责人员)**: + - **进度**:已正式启动 `text2sql` 的微调工作。 + - **资源**:租用了两张 4090 显卡进行训练。 + - **预估**:微调时间预计需要 50-60 小时,单次微调成本约为 250 元。 + - **现状**:QLoRA 代码框架已搭建,bird 数据集已准备就绪,目前处于模型训练的初期阶段。 +- **前端开发模块(执行人:李果霖、李文韬)**: + - **进度**:页面开发基本完成,实现了用户登录、注册及核心对话界面的静态展示。 + - **现状**:目前前端使用的是模拟数据(Mock Data),尚未接入真实后端接口。 + - **难点**:需要尽快与后端进行联调,替换模拟数据,验证交互逻辑。 +- **后端开发模块(执行人:梁峻耀、伊木然、王利蓉)**: + - **伊木然**:初步完成了后端端口的搭建,并利用 FastAPI 自带工具完成了各基础接口的连通性测试,确保接口畅通。 + - **梁峻耀**: + - 新增了登录、注册及邮箱验证码功能,并集成了 Redis 缓存以优化验证码存储。 + - 完成了项目结构的重构,使其更符合 FastAPI 最佳实践。 + - 完成了 SQLAlchemy ORM 模型的异步适配,提升了数据库操作性能。 + - 已编写对应功能的单元测试,并开始与前端进行接口联调。 + - **王利蓉**: + - 完成了聊天模块的代码结构搭建。 + - 实现了 API 接口的初步调用,目前返回的是模拟数据。 + - **待办**:距离真实可用的智能聊天功能,还需完成数据库持久化存储和 AI 模型的实际集成。 + +### 3. UML图表设计与系统文档 + +- **UML设计**: + - 本周实际启动了系统总体用例图的绘制。 + - 正在进行主要业务功能(如自然语言查询、会话管理)的活动图、顺序图和类图的绘制工作,旨在完善系统设计文档。 +- **代码规范**: + - 全体开发人员按照团队代码规范正在进行首轮自查,重点检查了变量命名、注释规范和异常处理,为第10周的代码互评做好了准备。 + +## 后续任务安排 + +### (1)项目开发重点: + +- **前后端联调**:下周重点攻克前后端数据对接,替换前端的模拟数据,实现从前端到数据库的完整链路打通。 +- **AI集成**:关注 `text2sql` 微调结果,尝试将其集成到聊天模块中,替换目前的模拟返回。 +- **数据库持久化**:完善聊天记录等数据的持久化存储,确保会话上下文可追溯。 + +### (2)具体分工确认: + +- **会议记录**:李果霖(下周轮换) +- **会议主持**:项目经理 +- **文档管理**:李果霖 +- **周计划与周总结**:伊木然 +- **进度管理**:王利蓉,李果霖 +- **日常考勤**:王利蓉 +- **前端开发**:李果霖、李文韬(重点:联调) +- **后端开发**:梁峻耀(重点:联调与优化)、伊木然(重点:接口维护)、王利蓉(重点:AI集成) +- **AI模型训练**:AI负责人员(重点:监控微调进度) + +### (3)会议安排: + +- 下周例会时间:11月30日 星期日晚上7:00-8:00 +- 联调专项会议:根据前后端对接情况随时召开 + +## 问题总结 + +### 已解决问题: + +- 迭代开发计划第二稿顺利提交。 +- 后端基础架构搭建完成并测试通过。 +- AI微调工作正式启动,算力资源和数据集已就位。 + +### 待解决问题: + +- **前后端联调**:目前前端仍依赖模拟数据,需加快对接进度。 +- **AI集成**:聊天功能尚不可用,需等待模型微调完成并进行集成测试。 +- **数据库持久化**:聊天模块的数据库存储逻辑尚需完善。 + +## 小组协作情况总结 + +**协作情况**: 本周前后端及AI组分工明确,各自推进顺利。后端在架构重构和接口测试上配合默契,为前端联调提供了有力支持。前端页面开发迅速,等待后端数据接入。 + +## 一周纪律情况总结 + +**纪律情况**: 全员按时参加会议,开发任务按计划推进,无缺勤或延误情况。 + +## 备注 + +- AI 微调时间较长且成本较高,需密切监控训练过程,避免资源浪费。 +- 前后端联调是下周的核心风险点,建议每日互通接口对接情况。 \ No newline at end of file diff --git a/doc/process/weekly/week-09/group/weekly-plan-09.md b/doc/process/weekly/week-09/group/weekly-plan-09.md new file mode 100644 index 0000000..3fe81e2 --- /dev/null +++ b/doc/process/weekly/week-09/group/weekly-plan-09.md @@ -0,0 +1,37 @@ +# 小组周计划-第9周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-17 +**结束时间:** 2025-11-23 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人** | **情况说明** | +|----------|--------------|------------|--------------| +| 1 | **迭代开发计划第二稿制定与提交** | 全体成员 | 基于第8周完成的迭代开发计划第一稿,结合老师反馈和团队实际情况进行修改完善,形成第二稿,并于**周五晚10:00前提交至头歌平台** | +| 2 | **UML图表设计与绘制** | 全体成员 | **【本周重点】**实际启动并完成系统总体用例图、主要业务功能的活动图、顺序图和类图的绘制工作,完善系统设计文档 | +| 3 | **V1.0前端功能深入开发** | 前端开发人员 |**【本周重点】** 在初步定稿基础上,持续推进前端功能开发,完成用户界面与核心交互功能的实现 | +| 4 | **V1.0后端功能深入开发** | 后端开发人员 |**【本周重点】** 在初步定稿基础上,深入开发后端各模块功能,完善API接口实现 | +| 5 | **前后端联调测试** | 前后端开发人员 | 对已开发完成的接口进行前后端联调测试,验证功能完整性和数据准确性 | +| 6 | **AI模型实验启动** | AI负责人员 | **【本周启动】**正式启动AI模型实验工作,搭建QLoRA代码框架,准备bird数据集,开展首次模型训练实验 | +| 7 | **代码规范性自查** | 全体开发人员 | 按照代码规范要求对已有代码进行自查和优化,为第10周的代码规范性互评做准备 | + +## 小结 + +1. **文档完善重点:** 本周核心任务之一是完成迭代开发计划第二稿的修改与提交,需确保内容详实可行; +2. **设计工作补全:** 需实际完成UML图表的设计绘制工作,弥补上周未完成的设计任务; +3. **开发工作深化:** 在前端和后端初步定稿的基础上,全面推进各功能模块的深入开发; +4. **AI工作启动:** 本周正式启动AI模型实验,从理论研究转向实践操作; +5. **质量保障准备:** 开展代码规范性自查,为下周的代码互评工作做好准备; +6. **进度严格把控:** 各模块负责人需严格把控开发进度,确保各项任务按时完成。 + +--- + +## 【注】 + +1. **迭代计划提交:** 迭代开发计划第二稿需在11月21日(周五)晚10:00前完成平台提交,请相关成员提前准备; +2. **开发进度要求:** 各开发模块需在本周取得实质性进展,为Alpha版本的顺利完成奠定基础; +3. **测试工作提醒:** 在开发过程中需同步进行测试,确保代码质量,减少后续修改成本; +4. **文档规范:** UML图表绘制需符合课程规范要求,体现系统设计的完整性和逻辑性, 大家分工完成,互相配合。 \ No newline at end of file diff --git a/doc/process/weekly/week-09/group/weekly-summary-09.md b/doc/process/weekly/week-09/group/weekly-summary-09.md new file mode 100644 index 0000000..b9033a4 --- /dev/null +++ b/doc/process/weekly/week-09/group/weekly-summary-09.md @@ -0,0 +1,35 @@ +# 小组周总结-第9周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-17 +**结束时间:** 2025-11-23 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | 迭代开发计划第二稿制定与提交 | 完成 | 基于第8周完成的迭代开发计划第一稿,结合老师反馈和团队实际情况进行修改完善,已形成第二稿并按时提交 | +| 2 | UML图表设计与绘制 | 完成 | 已完成系统总体用例图、主要业务功能的活动图、顺序图和类图的绘制工作,系统设计文档已完善 | +| 3 | V1.0前端功能深入开发 | 基本完成 | 页面开发基本完成,目前前端使用模拟数据进行展示,等待与后端进行数据对接 | +| 4 | V1.0后端功能深入开发 | 部分完成 | 发送验证码、用户登录、注册等核心功能已完成开发,其他模块功能正在持续推进中 | +| 5 | 前后端联调测试 | 进行中 | 已开始初步联调测试,但前后端对接仍需进一步优化和完善 | +| 6 | AI模型实验启动 | 完成 | 已正式启动AI模型实验,text2sql微调工作已开始进行 | +| 7 | 代码规范性自查 | 进行中 | 全体开发人员将在第十周完成代码规范性自查工作 | + +## 小结 + +1. **文档设计成果显著:** 本周顺利完成迭代开发计划第二稿和UML图表设计工作,为后续开发提供了清晰的指导; +2. **开发工作稳步推进:** 前后端核心功能开发取得实质性进展,基础功能模块已基本实现; +3. **技术难点逐步突破:** AI模型实验正式启动,text2sql微调工作有序开展; +4. **协作效率有待提升:** 前后端联调过程中暴露出对接不够顺畅的问题,需要加强沟通协调; +5. **进度把控基本到位:** 各模块负责人能够按照计划推进工作,整体进度符合预期。 + +--- + +## 【注】 + +1. **联调优化需求:** 建议组织专门的前后端联调会议,解决当前对接不畅的问题; +2. **技术分享建议:** 希望安排关于前后端高效协作和接口规范的技术交流; +3. **进度管理提醒:** 各模块负责人需进一步加强进度跟踪,确保关键任务按时完成; diff --git a/doc/process/weekly/week-09/members/liangjunyao-summary-plan-09.md b/doc/process/weekly/week-09/members/liangjunyao-summary-plan-09.md new file mode 100644 index 0000000..401200a --- /dev/null +++ b/doc/process/weekly/week-09/members/liangjunyao-summary-plan-09.md @@ -0,0 +1,33 @@ +# 个人周总结-第9周 + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-11-17 +**结束时间**:2025-11-23 + +#### 本周任务完成情况 + +| 序号 | 计划内容 | 是否完成 | 情况说明 | +| ---- | -------------------------- | -------- | ------------------------------------------------------------ | +| 1 | 迭代开发计划第二稿参与修订 | 团队完成 | 参与团队讨论并根据老师反馈完成修订,确保后端计划与整体规划一致。 | +| 2 | UML类图与顺序图绘制 | 团队完成 | 已完成后端相关模块(用户管理、项目管理、对话模块)的详细类图与核心业务流程顺序图,并提交供团队评审。 | +| 3 | V1.0后端功能深入开发 | 完成 | 新增登录、注册、邮箱验证码功能,并集成 Redis 缓存验证码;编写了对应单元测试。 | +| 4 | 后端框架调整与优化 | 完成 | 重构项目结构,新增 `server.py` 明确 FastAPI 生命周期流程;优化模块划分,形成清晰的分层架构,提高代码可维护性。 | +| 5 | 日志功能集成 | 完成 | 实现结构化日志类,支持控制台与文件双输出,日志保存至 `log/` 目录,统一格式并支持请求追踪与操作审计。 | +| 6 | 环境配置管理优化 | 完成 | 将配置从 `.env` 迁移至 `config.yaml`,通过 `Config` 类统一加载开发与部署环境配置,逻辑清晰且易于扩展。 | +| 7 | 包管理工具迁移 | 未完成 | Poetry 迁移暂未实施,但计划在服务器部署阶段直接锁定稳定依赖版本,确保部署环境一致性。 | +| 8 | ORM异步操作适配 | 完成 | 已完成 SQLAlchemy ORM 模型的异步适配,支持 `async/await`,优化数据库连接性能与接口并发能力。 | +| 9 | 华为云服务器环境部署 | 未完成 | 代金券仍未到账,但已通过 radmin 成功实现前后端局域网联调,本地开发环境稳定可用。 | +| 10 | 前后端联调测试配合 | 完成 | 与前端协同完成接口联调,解决认证、数据格式及错误处理问题,验证登录、注册、验证码等核心流程。 | +| 11 | 代码规范性 | 进行中 | 按团队规范对已开发代码进行自查,优化命名、注释与结构,确保代码质量与可读性。 | + +#### 小结 + +**任务推进与成果**: +本周聚焦于后端架构升级与V1.0核心功能落地。成功完成框架重构(`server.py` 引入、分层优化)、日志系统集成、配置文件迁移、ORM异步适配等关键基础设施改进,显著提升系统可维护性、可观测性与性能。同时,用户认证相关功能(注册、登录、邮件验证码)已完整实现并完成联调,为Alpha版本交付打下坚实基础。 + +**协作与沟通**: +积极参与迭代计划修订与UML设计评审,确保技术实现与项目规划对齐。通过 radmin 成功建立前后端联调环境,高效解决接口对接问题,保障开发节奏。 + +**技术优化与风险应对**: +尽管 Poetry 迁移暂未实施,但已制定部署阶段的依赖管理策略;华为云部署因外部因素延迟,但本地+局域网联调方案有效保障开发进度。后续将持续推进部署准备,确保代金券到位后快速完成上线。 \ No newline at end of file diff --git a/doc/process/weekly/week-09/members/liangjunyao-weekly-plan-09.md b/doc/process/weekly/week-09/members/liangjunyao-weekly-plan-09.md new file mode 100644 index 0000000..ee429e0 --- /dev/null +++ b/doc/process/weekly/week-09/members/liangjunyao-weekly-plan-09.md @@ -0,0 +1,33 @@ +# 个人周计划-第9周 + +## 姓名与起止时间 +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-11-17 +**结束时间**:2025-11-23 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ------------------------------ | ------------ | ------------------------------------------------------------ | +| 1 | **迭代开发计划第二稿参与修订** | 全体成员 | **【周三前完成】** 基于老师反馈和团队实际情况,参与迭代开发计划第二稿的讨论与修订,确保后端开发计划与整体规划保持一致 | +| 2 | **UML类图与顺序图绘制** | 全体成员 | **【本周重点,周五前完成】** 负责完成后端相关模块的详细类图和核心业务流程顺序图,包括用户管理、项目管理、对话模块的类关系和交互流程 | +| 3 | **V1.0后端功能深入开发** | 后端开发人员 | **【本周重点】** 完善用户管理、项目管理模块的API接口,完成对话模块核心功能开发,确保接口符合规范并编写完整的单元测试 | +| 4 | **后端框架调整与优化** | 个人 | **【本周重点】** 重构后端项目结构,优化模块划分,提高代码可维护性和扩展性,建立清晰的分层架构 | +| 5 | **日志功能集成** | 个人 | 集成结构化日志系统,统一日志格式,实现请求追踪、操作审计和错误日志记录,便于问题排查和系统监控 | +| 6 | **环境配置管理优化** | 个人 | **【优先级高】** 将.env配置文件改为config.yaml,实现开发环境与部署环境的配置分离,完善配置读取逻辑 | +| 7 | **包管理工具迁移** | 个人 | 从requirements.txt迁移至poetry管理项目依赖,统一依赖版本锁定,改善开发体验和部署一致性 | +| 8 | **ORM异步操作适配** | 个人 | 将SQLAlchemy ORM类适配异步操作,优化数据库连接性能,支持async/await语法,提升接口并发处理能力 | +| 9 | **华为云服务器环境部署** | 个人 | **【条件性任务】** 待华为云代金券发放后立即完成服务器环境部署,同步更新部署配置 | +| 10 | **前后端联调测试配合** | 前端开发人员 | 积极配合前端进行接口联调测试,解决数据格式、权限认证和错误处理问题,建立稳定的联调环境 | +| 11 | **代码规范性自查** | 个人 | **【周五前完成】** 按照团队代码规范要求,对已实现的后端代码进行自查和优化,包括命名规范、注释完善、代码结构等 | + +## 小结 + +**核心任务安排**:本周重点完成后端框架调整(任务4)和V1.0功能开发(任务3),同时在环境配置优化(任务6)和包管理迁移(任务7)方面取得实质性进展。UML设计(任务2)需在周三前完成,为技术重构提供指导。 + +**技术架构升级**:本次后端框架调整涉及多个基础设施组件升级,包括配置管理、日志系统、依赖管理和异步ORM适配,这些改进将显著提升系统的可维护性、可观测性和性能表现。 + +**风险控制**:技术重构任务较多,需合理安排时间,避免影响核心功能开发。配置管理变更(任务6)和异步改造(任务8)可能引入兼容性问题,需充分测试。华为云部署(任务9)受外部因素影响,需做好备选方案。 + +**开发流程优化**:通过poetry统一依赖管理(任务7)和代码规范自查(任务11),建立更规范的开发流程,为团队协作和项目维护奠定更好基础。 \ No newline at end of file diff --git a/doc/process/weekly/week-09/members/liguolin-weekly-plan-09.md b/doc/process/weekly/week-09/members/liguolin-weekly-plan-09.md new file mode 100644 index 0000000..c4539b9 --- /dev/null +++ b/doc/process/weekly/week-09/members/liguolin-weekly-plan-09.md @@ -0,0 +1,30 @@ +# 个人周计划-第9周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-11-17 + +结束时间:2025-11-23 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | -------------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **V1.0前端功能深入开发** | 前端开发人员 | **【本周重点】** 接续第8周的API模块重构,开展界面与逻辑开发。具体任务包括:1. 编写登录/注册界面;
2. 完成基础项目创建界面,并实现查询结果展示功能。
3. 努力实现核心对话界面; | +| 2 | **前后端联调测试** | 前端开发人员 | 对第8周已完成的V1.0接口及本周新增接口,与同伴进行联调测试,验证功能完整性。 | +| 3 | **UML图表设计与绘制** | 全体成员 | **【本周重点】** 参与团队协作,基于第8,9周学习与讨论,完成负责的事件图、活动图、顺序图及类图相关工作。 | +| 4 | **迭代开发计划第二稿制定与提交** | 全体成员 | 参与团队讨论,结合老师对第一稿的反馈,修改并完善第二稿,于周五前提交。 | +| 5 | **代码规范性自查** | 全体开发人员 | 依据团队规范,对第8周重构及本周新编写的前端代码进行自查与优化,为第10周代码互评做准备。 | +| 6 | **关注AI模型实验启动进展** | AI负责人员 | 跟进第8周推迟任务,关注AI模型实验的启动进展,确保设计支持后续集成。 | +| 7 | **整理个人笔记与撰写周报** | 个人 | 整理本周前端开发、UML设计与联调测试相关笔记,撰写个人周总结。 | + +## 小结 + +1. **核心开发任务**:本周我将重点投入**前端功能开发**,完成登录/注册、项目创建及核心对话三大关键界面的实现,将上周重构成果转化为具体代码。 +2. **联调与设计补全**:在开发过程中,积极参与**前后端联调测试**,保障V1.0接口的可用性。同时完成上周遗留的**UML图设计任务**,完善项目设计文档。 +3. **文档与规范**:参与**迭代计划第二稿**的修订,按时完成**代码规范自查**,为下周的代码互评做好充分准备。 + diff --git a/doc/process/weekly/week-09/members/liguolin-weekly-summary-09.md b/doc/process/weekly/week-09/members/liguolin-weekly-summary-09.md new file mode 100644 index 0000000..685e0a8 --- /dev/null +++ b/doc/process/weekly/week-09/members/liguolin-weekly-summary-09.md @@ -0,0 +1,30 @@ +# 个人周总结-第9周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-11-17 + +结束时间:2025-11-23 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **完成情况** | **情况说明** | +| -------- | ------------------------ | ------------ | ------------------------------------------------------------ | +| 1 | **V1.0前端功能深入开发** | **进行中** | 本周重点推进了用户鉴权模块,完成了登录与注册功能的页面开发。目前**正在配合后端替换模拟接口(Mock Data)**,进行真实数据的联调测试。 | +| 2 | **UML图表设计与绘制** | **完成** | 根据团队分工,我**主要负责并完成了系统顺序图(Sequence Diagram)的设计与提交**,梳理了关键业务流程的对象交互逻辑。 | +| 3 | **前后端接口测试** | **进行中** | 配合后端推进登录/注册接口的联调,重点验证JWT Token的获取与存储流程。目前正在逐步替换前端的模拟数据,确保基础访问权限控制的真实性。 | +| 4 | **AI模型实验启动** | **进行中** | **【主动推进】** 接手了AI微调任务。已选定基座模型为 **codellama-instruction-13hf**,配合 **Bird数据集**。选用 **2\*4090** 或**1\*A100** 的显卡算力方案,已经搭建Pytorch实验环境。 | +| 5 | **迭代开发计划第二稿** | **完成** | 参与团队讨论,协助修改了迭代计划中的进度安排,确保计划符合当前实际开发节奏(特别是AI部分的调整)。 | +| 6 | **代码规范性自查** | **进行中** | 已启动初步的代码检查,重点关注变量命名与注释规范。因需兼顾开发与AI调研,**计划于下周五前完成全部代码的深度规范化自查与优化**,为互评做准备。 | +| 7 | **整理个人笔记与周报** | **完成** | 整理了本周关于顺序图,前端接口对接,CodeLlama模型参数、微调实验环境的笔记,并撰写了本总结。 | + +## 小结 + +1. **前端开发进展**:本周前端工作稳步推进,重点完成了登录与注册模块的页面实现。目前正处于**从模拟数据向真实接口切换**的关键阶段,已与后端建立初步连接,下周将完成核心对话界面的逻辑对接。 +2. **AI任务实质启动**:本周我投入了大量精力启动AI微调工作。技术路线已明确为 **Codellama-Instruction-13HF** 模型配合 **Bird数据集**。硬件方面选用 **双卡4090** 或 **单卡A100** ,预计下周确定方案并开始训练。 +3. **设计与文档**:完成了**UML顺序图**的绘制与提交,从逻辑层面理清了系统交互。同时协助团队完成了迭代计划的更新。 +4. **后续计划**:鉴于开发任务繁重,**代码规范性自查**将延续至下周进行。我计划在下周五前完成所有代码的格式化与优化,确保以高质量代码迎接团队互评。 \ No newline at end of file diff --git a/doc/process/weekly/week-09/members/liwentao-weekly-plan-09.md b/doc/process/weekly/week-09/members/liwentao-weekly-plan-09.md new file mode 100644 index 0000000..1662e8e --- /dev/null +++ b/doc/process/weekly/week-09/members/liwentao-weekly-plan-09.md @@ -0,0 +1,34 @@ +# 个人周计划-第9周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-11-17 + +**结束时间**:2025-11-23 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ----------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | V1.0 (Alpha) 前端功能深入开发 | 李果霖 | **[本周重点]** 基于第8周重构分离出的API模块,本周需与李果霖协作,全面推进核心功能的界面实现。重点是完成用户认证(登录/注册)、项目管理和核心对话界面的UI编码,并实现前端交互逻辑。 | +| 2 | 前后端联调测试 | 后端开发人员 | **[本周重点]** 在V1.0接口文档初步定稿的基础上,主动与后端开发人员对接,启动联调。首要目标是打通“用户注册-登录-获取项目列表”的完整流程,验证功能完整性和数据准确性。 | +| 3 | 代码规范性自查 | 个人 | 按照团队代码规范,对本周新编写的功能代码进行实时自查。同时,为第10周的代码规范性互评做准备,Review第8周搭建的框架代码,确保其符合规范。 | +| 4 | 参与 UML 图表设计与绘制 | 全体成员 | 参与绘制与前端交互逻辑密切相关的活动图和顺序图,确保图表清晰反映用户在界面的操作流程,完善系统设计文档。 | +| 5 | 参与迭代开发计划第二稿制定 | 全体成员 | 参与团队讨论,基于第8周的开发实际情况,从前端角度对功能优先级和工作量进行反馈,协助完善第二稿计划,确保周五前提交至平台。 | +| 6 | 一周总结 | 个人 | 整理本周的开发产出(功能代码、联调成果)和文档,复盘联调中遇到的问题,参与制定小组下周计划,并结合小组计划制定个人计划。 | + + + +## 小结 + +**核心任务:** 本周的个人工作重心从第8周的“框架重构”正式转向“功能实现”。我的首要任务是编码实现用户、项目和对话等核心界面,并完成所有前端基本交互逻辑。 + +**联调启动:** 作为前端开发,本周的另一个重点是启动与后端的联调。我将主动与后端协作,力争打通从注册到获取项目列表的第一个完整数据链路,替换掉前端的模拟数据。 + +**设计补全:** 落实上周推迟的计划,我将积极参与UML图表绘制,特别是从用户操作视角出发,主导活动图和顺序图的设计,确保前端逻辑与系统设计一致。 + +**质量保障:** 在编码的同时,我将严格执行代码规范性自查,不仅是针对新代码,也包括复查已有代码,为第10周的互评做好准备。 \ No newline at end of file diff --git a/doc/process/weekly/week-09/members/liwentao-weekly-summary-09.md b/doc/process/weekly/week-09/members/liwentao-weekly-summary-09.md new file mode 100644 index 0000000..beff4ab --- /dev/null +++ b/doc/process/weekly/week-09/members/liwentao-weekly-summary-09.md @@ -0,0 +1,36 @@ +# 个人周总结-第9周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-17 +**结束时间:** 2025-11-23 + + + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | -------------------------- | -------- | ------------------------------------------------------------ | +| 1 | V1.0 (Alpha) 前端功能开发 | 基本完成 | 完成了用户界面与核心交互功能的实现。目前前端页面开发已基本完成,但主要仍使用模拟数据进行展示,等待与后端进行进一步的数据对接。 | +| 2 | 前后端对接测试 | 进行中 | 已启动初步对接,目前后端发送验证码、用户登录、注册等功能已完成,需进一步优化。 | +| 3 | 参与 UML 图表设计与绘制 | 完成 | 根据上周计划,参与绘制了系统活动图,完成了系统设计文档的完善工作。 | +| 4 | 参与迭代开发计划第二稿制定 | 完成 | 结合本周实际开发进度,协助团队结合老师反馈完成了迭代计划的修改与提交。 | +| 5 | 代码规范自查 | 进行中 | 代码规范自查持续进行中,重点关注变量名与注释规范,下周继续自查。 | +| 6 | 一周总结 | 完成 | 2025-11-23 整理了本周开发产出,参与制定了小组下周计划,并完成了本周总结 | + + + +## 对团队工作的建议 + +1. **组织联调专项会议**:建议下周初立即召开专项会议,面对面解决接口字段定义与实际实现不一致的问题。 +2. **后端接口加速推进**:目前前端页面已基本完成并使用模拟数据,建议后端加快除登录注册外其他模块的开发进度,以便前端能尽快替换模拟数据,完成全链路测试。 +3. **保持代码规范**:下周即将进行代码互评,建议团队成员在提交代码前再次检查注释清晰度,确保互评效率。 + + + +## 小结 + +1. **功能实现落地**:本周个人工作重心成功从框架转向功能,完成了核心界面的UI编码和基于模拟数据的交互逻辑。 +2. **联调进展与挑战**:启动了与后端的联调工作,虽然验证了登录/注册流程,但整体对接流畅度有待提升,这将是下周的工作重点。 +4. **常规总结**:2025-11-23 按时完成了本周的开发总结和下周计划的制定。 diff --git a/doc/process/weekly/week-09/members/wanglirong-weekly-plan-09.md b/doc/process/weekly/week-09/members/wanglirong-weekly-plan-09.md new file mode 100644 index 0000000..e6bb1bc --- /dev/null +++ b/doc/process/weekly/week-09/members/wanglirong-weekly-plan-09.md @@ -0,0 +1,38 @@ +# 个人周计划-第9周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-17 +**结束时间:** 2025-11-23 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | **迭代开发计划第二稿参与修订** | 全体成员 | **【周三前完成】** 基于老师反馈和团队实际情况,参与迭代开发计划第二稿的讨论与修订,重点关注AI模块和文本到SQL功能的计划安排 | +| 2 | **UML类图与顺序图绘制** | 全体成员 | **【周五前完成】** 负责绘制自然语言查询、会话管理等核心业务功能的类图和顺序图,明确类关系和交互流程 | +| 3 | **文本到SQL(DQL)功能开发** | 个人 | **【本周重点】** 深入开发AI代理的文本到SQL功能,集成术语库的自然语言查询代码,完善查询逻辑和结果处理 | +| 4 | **AI模型QLoRA实验启动** | AI负责人员 | **【本周启动】** 正式启动QLoRA模型实验,搭建代码框架,准备bird数据集,开展首次模型训练 | +| 5 | **用户会话管理API开发** | 后端开发人员 | 完善会话管理相关API接口,包括会话创建、历史记录查询、会话切换等功能 | +| 6 | **前后端联调测试配合** | 前端开发人员 | 积极配合前端进行文本查询、会话管理相关接口的联调测试,确保数据传输准确性 | +| 7 | **代码规范性自查与优化** | 个人 | **【周五前完成】** 按照团队代码规范要求,对已实现的AI模块和API代码进行自查和优化 | +| 8 | **技术文档整理** | 个人 | 整理AI模块的技术实现文档,包括文本到SQL的处理流程、模型调用逻辑等 | + +## 小结 + +1. **AI功能开发重点:** 本周重点推进文本到SQL功能的深入开发,确保自然语言查询的核心功能稳定可靠; +2. **模型实验启动:** 正式启动QLoRA模型实验工作,从理论研究转向实践操作; +3. **文档设计任务:** 需要完成UML类图和顺序图的绘制工作,为系统设计提供完整文档支持; +4. **时间节点把控:** 迭代计划修订周三前完成,UML设计周五前完成,需合理安排时间; +5. **希望获得的帮助:** 希望在AI模型训练和文本到SQL技术实现方面获得更多指导,特别是在查询优化和结果准确性提升方面。 + +--- + +## 【注】 + +1. **迭代计划修订**:本周三前需完成迭代开发计划第二稿的修订工作,请提前准备相关材料; +2. **UML设计提交**:系统类图与顺序图设计需在本周五前完成并提交; +3. **AI实验启动**:QLoRA模型实验为本周重点任务,需取得实质性进展; +4. **代码规范自查**:为准备第10周代码互评,请在本周五前完成个人代码规范性检查。 \ No newline at end of file diff --git a/doc/process/weekly/week-09/members/wanglirong-weekly-summary-09.md b/doc/process/weekly/week-09/members/wanglirong-weekly-summary-09.md new file mode 100644 index 0000000..46a9c40 --- /dev/null +++ b/doc/process/weekly/week-09/members/wanglirong-weekly-summary-09.md @@ -0,0 +1,45 @@ +# 个人周总结-第9周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-17 +**结束时间:** 2025-11-23 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | 迭代开发计划第二稿参与修订 | 完成 | 积极参与团队讨论,主要负责在小组讨论后对迭代开发计划进行优化完善工作 | +| 2 | UML类图与顺序图绘制 | 完成 | 负责绘制"自然语言数据操作与安全确认"和"数据分析报表生成与管理"两个核心业务流程的活动图 | +| 3 | 文本到SQL(DQL)功能开发 | 部分完成 | 完成聊天模块代码结构搭建,API接口可调通但返回模拟数据,距离真实可用还需数据库持久化和AI集成 | +| 4 | AI模型QLoRA实验启动 | 完成 | 配合AI负责人员正式启动QLoRA模型实验,text2sql微调工作已开始 | +| 5 | 用户会话管理API开发 | 部分完成 | 完成会话管理基础API接口开发,包括会话创建、历史记录查询等功能 | +| 6 | 前后端联调测试配合 | 进行中 | 积极配合前端进行接口联调,目前使用模拟数据测试通过,等待真实数据对接 | +| 7 | 代码规范性自查与优化 | 未完成 | 本周主要完成代码,自查推迟到下周 | +| 8 | 技术文档整理 | 完成 | 整理完成AI模块技术实现文档,包括文本到SQL处理流程和模型调用逻辑 | + +## 对团队工作的建议 + +1. **技术协作加强**:建议前后端开发人员建立更紧密的协作机制,提高接口对接效率; +2. **进度同步优化**:建议每日进行简短的进度同步,及时发现和解决开发中的技术障碍; +3. **知识共享促进**:建议团队成员定期分享技术难点和解决方案,提升整体开发水平; +4. **测试流程规范**:建议建立更完善的测试流程,确保各模块功能稳定可靠。 + +## 小结 + +1. **文档设计成果**:顺利完成迭代开发计划优化和核心业务流程活动图设计,为项目提供重要文档支持; +2. **核心功能进展**:聊天模块基础架构搭建完成,API接口初步调通,为后续开发奠定基础; +3. **业务流程明确**:通过绘制自然语言数据操作和报表生成的活动图,进一步明确了核心业务逻辑; +4. **协作效果良好**:在文档修订和UML设计中与团队成员配合顺畅,贡献突出; +5. **待完善工作**:聊天功能的数据库持久化和AI智能集成需要在下周重点推进。 + +--- + +## 【注】 + +1. **技术指导需求**:希望在AI模型集成和数据库优化方面获得更多实战指导; +2. **业务流程验证**:需要团队对绘制的活动图进行评审验证,确保业务流程准确性; +3. **进度管理确认**:已按时完成个人总结提交,相关文档已上传至代码托管平台; +4. **重点任务规划**:下周将重点推进聊天功能的智能化和数据库集成工作。 \ No newline at end of file diff --git a/doc/process/weekly/week-09/members/yimuran-weekly-plan-09.md b/doc/process/weekly/week-09/members/yimuran-weekly-plan-09.md new file mode 100644 index 0000000..1740d0f --- /dev/null +++ b/doc/process/weekly/week-09/members/yimuran-weekly-plan-09.md @@ -0,0 +1,28 @@ +# 个人周计划-第9周 + +## 姓名和起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-17 + +**结束时间:** 2025-11-23 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人** | **情况说明** | +| -------- | -------------------------------- | -------------- | ------------------------------------------------------------ | +| 1 | **迭代开发计划第二稿制定与提交** | 全体成员 | 2025-11-21(周五)前。参与团队讨论,结合老师反馈修改迭代计划。 | +| 2 | **UML图表设计与绘制** | 全体成员 | **【周五前完成】** 负责绘制自然语言查询、会话管理等核心业务功能的类图和顺序图,明确类关系和交互流程 | +| 3 | **V1.0后端功能深入开发** | 个人 (伊木然) | **【本周重点】** 在初步定稿基础上,深入开发后端各模块功能,完善API接口实现 | +| 4 | **前后端联调测试** | 前后端开发人员 | 积极配合前端(李果霖、李文韬),对已开发完成的接口进行前后端联调测试,验证功能。 | +| 5 | **AI模型推进(关注)** | AI负责人员 | 关注AI组(王利蓉)的QLoRA实验启动进展,并协助调试模型。 | +| 6 | **代码规范性自查** | 个人 | 2025-11-21(周五)前。按照团队代码规范,对个人已编写的后端代码(FastAPI, Python)进行自查和优化,为第10周的代码互评做准备。 | + +## 小结 + +1. **核心开发任务**:本周我将重点投入**后端功能开发**,根据接口初稿实现相关接口。 +2. **联调与设计补全**:在开发过程中,积极参与**前后端联调测试**,保障V1.0接口的可用性。同时完成上周遗留的**UML图设计任务**,完善项目设计文档。 +3. **文档与规范**:参与**迭代计划第二稿**的修订,按时完成**代码规范自查**,为下周的代码互评做好充分准备。 diff --git a/doc/process/weekly/week-09/members/yimuran-weekly-summary-09.md b/doc/process/weekly/week-09/members/yimuran-weekly-summary-09.md new file mode 100644 index 0000000..f8f0b66 --- /dev/null +++ b/doc/process/weekly/week-09/members/yimuran-weekly-summary-09.md @@ -0,0 +1,33 @@ +# 个人周总结-第9周 + +## 姓名和起止时间 + +**姓  名:** 伊木然 + + **团队名称:** 3班-葫芦娃救bug + + **开始时间:** 2025-11-17 + +**结束时间:** 2025-11-23 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **是否完成** | **情况说明** | +| -------- | -------------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **迭代开发计划第二稿制定与提交** | **完成** | 参与了团队讨论,结合老师反馈对计划进行了修订,确保了第二稿在周五前按时提交至平台。 | +| 2 | **UML图表设计与绘制** | **初步完成** | 按计划完成了系统总体用例图及主要业务功能(如自然语言查询、会话管理)的活动图、顺序图和类图绘制,完善了系统设计文档。 后期进行细调。 | +| 3 | **V1.0后端功能深入开发** | **部分完成** | **[本周重点]** 深入开发了后端核心模块,目前已成功实现**发送验证码、用户登录、注册**等关键API接口,项目管理相关接口正在持续开发中。 | +| 4 | **前后端联调测试** | **进行中** | 配合前端开发人员(李果霖、李文韬)开始了初步联调,验证了部分接口的连通性,但数据对接过程仍存在一些不够顺畅的问题,需下周重点解决。 | +| 5 | **后端端口的搭建** | **完成** | 初步完成了后端端口的搭建,并利用 FastAPI 自带工具完成了各基础接口的连通性测试,确保接口畅通。 | +| 6 | **代码规范性自查** | **进行中** | 按照团队规范对个人编写的FastAPI代码进行了自查和优化,修正了部分命名和注释问题,为互评做好了准备。 | + +## 对团队工作的建议 + +1. **加强联调沟通**:本周联调过程中暴露出前后端对接不够顺畅的问题,建议下周初组织一次专门的**前后端联调会议**,面对面解决接口格式和数据传输的细节问题。 +2. **接口文档同步**:建议在开发过程中实时更新接口文档,确保前后端依据的接口定义保持一致,减少因文档滞后导致的返工。 + +## 小结 + +1. **后端开发取得突破**:本周完成了用户认证(登录/注册/验证码)这一核心功能的后端实现,标志着后端开发进入了实质性产出阶段。 +2. **文档工作圆满收尾**:顺利完成了迭代计划第二稿和UML图表的设计绘制,系统设计文档已趋于完善。 +3. **联调挑战**:虽然启动了联调,但进度略滞后于预期,下周需将重心转移到解决联调阻碍上,确保数据能够真实流通。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/group/weekly-minutes-10.md b/doc/process/weekly/week-10/group/weekly-minutes-10.md new file mode 100644 index 0000000..e101d01 --- /dev/null +++ b/doc/process/weekly/week-10/group/weekly-minutes-10.md @@ -0,0 +1,117 @@ +# 小组会议纪要-第10周 + +## 会议记录概要 + +**团队名称**: 3班-葫芦娃救bug + +**指导老师**: 李友焕 + +**主 持 人**: 项目经理,梁峻耀 + +**记录人员**: 李果霖 + +**会议主题**: UML考核准备、前后端深度对接、小组文档整改与AI调试 + +**会议地点**: 线上会议 + +**会议时间**: 2025-11-30 16:00-18:20 + +**记录时间**: 2025-11-30 20:30 + +**参与人员**: 全体成员 + +## 会议内容 + +### 1. UML技能考核与文档工作部署 + +- **UML技能考核**: + - 明确考核形式和文档截止时间。全体成员需完成UML类图制作,将UML图导出为PNG图片并编写文档。 + - **截止时间**:十一周周五(12月5日)晚上10:00前提交。 +- **小组文档修改与校验**: + - 针对助教反馈及项目归档需求,需对前期的项目文档进行全面检查与修改。 + - **检查重点**:关注格式规范、备注(小结)的一致性,避免每周内容机械重复。需确保无明显逻辑错误和前后矛盾。 + - **任务分配**:采取交叉检查模式,每位成员负责检查1-2周的文档。 + - **提交要求**:修改后的文档及修改说明(Change Log)需在第11周周五前提交给助教。 + +### 2. 后端开发与接口进度汇报 + +- **已完成功能**: + - 对话与消息管理模块、会话管理功能API已开发完成。 + - 登录注册及用户设置接口运行稳定,已具备对接条件。 +- **待完善功能**: + - **报表查询与业务术语表**:目前尚未完成,是前端对接的主要阻塞点。 + - **公告功能**:明确了需拆分为“管理员管理公告”和“普通用户获取公告”两个独立接口进行实现。 +- **接口文档**: + - 会议结束后立即更新后端接口代码,并编写详细的API接口文档发送给前端负责人,以便进行联调。 + +### 3. 前端对接情况 + +- **对接现状**: + - 登录注册、用户设置等基础功能接口已稳定,对接已经完成。 + - **阻塞点**:对话与消息管理模块等新增API接口还未对接,等待后端“报表查询”和“业务术语表”接口的最终确认与发布。 +- **下一步计划**: + - 配合后端完成剩余接口的联调,确保页面数据的真实交互。 + +### 4. AI模型微调与调试 + +- **模型现状**: + - 第一次模型微调工作完成,在spider数据集上准确率达到0.779,并进一步4bit量化为gguf模型。 + - AutoDL平台服务器暂时无法扩容硬盘,采用体积更小的Spider数据集进行训练评估。 + - 目前调用开源模型如Claude,GPT4进行初步测试,但目前存在返回的SQL语句格式不规范、返回包含多余汉字等问题。 +- **部署方案探讨**: + - AutoDL不提供个人的公网域名,为节省成本,正在测试本地部署的量化模型效果。 + - 会议讨论了后续部署策略:是上传至服务器运行,还是采用内网穿透方式调用本地算力,需根据进一步测试结果决定。 +- **调试任务**: + - 会后需重点解决开源模型返回格式问题,确保能生成标准可执行的SQL语句。 + +## 后续任务安排 + +### (1)项目开发重点: + +- **AI模型攻坚**:换用微调后的模型,解决AI返回中文干扰SQL执行的问题,确定最终部署方案(服务器vs内网穿透)。 +- **接口补全**:后端需在周内完成报表查询和业务术语表接口,前端同步连调已经完成的核心功能接口。 +- **文档冲刺**:本周内完成所有历史文档的格式校验与内容修正。 + +### (2)具体分工确认: + +- **会议记录**:李文韬(下周轮换) +- **会议主持**:项目经理 +- **文档管理**:全体成员(每人认领2个文档/周次进行检查与修改)。 +- **周计划与周总结**:李文韬 +- **进度管理**:梁峻耀,李文韬 +- **日常考勤**:伊木然 +- **后端开发**:梁俊耀、伊木然(重点:报表、术语表、公告接口)。 +- **前端对接**:李文韬、李果霖(重点:对接已发布的稳定接口)。 +- **AI调试**:王利蓉、李文韬(重点:SQL格式化、部署测试)。 +- **API文档**:后端组(会后更新并发给前端负责人员)。 + +### (3)会议安排: + +- 下周例会时间:12月7日 星期日晚上7:00-8:00 +- 临时会议:若AI部署遇到重大技术瓶颈,随时召开技术研讨会。 + +## 问题总结 + +### 已解决问题: + +- 明确了文档修改的策略(主要改格式和明显错误,不需过度修改)。 +- 确认了后端核心模块API(对话、查询)的可用性。 + +### 待解决问题: + +- **接口缺失**:报表和术语表接口滞后,影响前端完整功能展示。 +- **部署方案未定**:AI模型的最终运行环境(云端/本地穿透)尚未敲定。 +- **AI返回格式**:开源模型输出不纯净(含汉字),后续会采用微调后的模型,通过拼接提示词等手段可以解决这个问题。 + +## 小组协作情况总结 + +**协作情况**: 本周会议重点讨论了跨模块的接口依赖问题。大家对文档修改任务达成了共识,避免了重复劳动。前端主动指出了等待后端接口的具体模块,沟通高效。 + +## 一周纪律情况总结 + +**纪律情况**: 会议开始时进行了设备检查,全员设备正常,无缺勤情况。 + +## 备注 + +- 文档修改说明一定要写清楚“修改了什么”和“为什么修改”,以便助教审核。 +- AI部分的对接是本周最核心的技术难点,建议后端人员投入更多精力对接Text2SQL完成核心模块。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/group/weekly-plan-10.md b/doc/process/weekly/week-10/group/weekly-plan-10.md new file mode 100644 index 0000000..dc30e21 --- /dev/null +++ b/doc/process/weekly/week-10/group/weekly-plan-10.md @@ -0,0 +1,30 @@ +# 小组周计划-第10周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11- 24 +**结束时间:** 2025-11- 30 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人** | **情况说明** | +|----------|--------------|------------|--------------| +| 1 | UML图表 | 全体成员 | 根据代码实现的实际情况,对类图、顺序图、活动图进行修正,确保图码一致。 | +| 2 | 代码规范性互评 | 全体人员 | 接收并审查其他小组的代码,依据代码规范检查表进行评审,记录问题并整理成代码审查报告提交 。 | +| 3 | α版本前端功能收尾 | 前端开发人员 | 完成登录注册,项目管理、基础NL部署与查询的最终逻辑开发,移除模拟数据,编写api层,接入真实接口 。 | +| 4 | α版本后端功能收尾 | 后端开发人员 | 依据迭代计划,完成Alpha版本所有核心代码编写(登录/注册、项目管理、基础NL部署与查询),确保功能逻辑完善 。 | +| 5 | 前后端对接 | 前后端开发人员 | 进行前后端对接,打通前端页面与后端API的数据交互,确保Alpha版本整体运行流畅 。 | +| 6 | AI模型接入与测试 | AI负责人员 | 将微调后的Text-to-SQL模型(基于Bird数据集)接入后端服务,验证自然语言转SQL功能 。 | +| 7 | UML技能考核准备 | 全体成员 | 复习UML建模知识,针对类图、顺序图、状态图等核心图表的绘制规范进行专项练习,准备技能考核。 | +| 8 | 代码规范性自查 | 全体人员 | 结合互评标准,对自己负责的模块代码进行优化,确保变量命名、注释和结构符合规范。 | + + + +## 小结 + +1. **开发收尾(α版):** 本周必须完成Alpha版本的所有功能代码开发,即“登录注册+项目管理+基础NL部署与查询”板块 ,确保系统基础功能完备; +2. **设计文档定稿:** UML图表不仅要完成绘制,更要保证与代码逻辑一致; +3. **代码规范:** “他评”与“自查”结合,通过评审别组代码反思自身不足,并输出具体的检查结果; +4. **技能考核:** 在推进项目的同时,全员需分配精力复习UML理论与实操,确保全员通过技能考核。 + diff --git a/doc/process/weekly/week-10/group/weekly-summary-10.md b/doc/process/weekly/week-10/group/weekly-summary-10.md new file mode 100644 index 0000000..77f5180 --- /dev/null +++ b/doc/process/weekly/week-10/group/weekly-summary-10.md @@ -0,0 +1,48 @@ +# 小组周总结-第10周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-24 + +**结束时间:** 2025-11-30 + + + +## 本周任务完成情况 + +| **序号** | **总结内容** | **是否完成** | **情况说明** | +| -------- | -------------------- | ------------ | ------------------------------------------------------------ | +| 1 | UML图表 | 持续进行 | 根据代码实现的实际情况,对类图、顺序图、活动图进行修正。小组成员持续复习准备周五的考核。 | +| 2 | 代码规范性互评与整改 | 未开始 | 开发流程规定这周一公布互评名单,但未公布,故搁置,规范自身代码等待互查 | +| 3 | α版本前端功能 | 基本完成 | 登录注册及用户设置接口运行稳定,对接已完成;其余部分等待后端接口稳定。 | +| 4 | α版本后端功能 | 部分完成 | 用户设置,管理员功能,会话接口已稳定 | +| 5 | 前后端对接 | 进行中 | 登录注册等基础功能已完成对接;目前主要问题在于等待后端接口 | +| 6 | AI模型微调与量化 | 完成 | 完成了在Spider数据集上的第一次微调(准确率0.779),并进行了4bit量化为gguf模型,部署在个人电脑使用内网穿透使用模型。 | +| 7 | 小组文档整改与校验 | 进行中 | 启动了历史文档的交叉检查模式,重点关注格式规范和备注一致性,每人负责1-2周文档 | +| 8 | AI部署方案测试 | 进行中 | 正在测试本地部署量化模型效果,将微调后的Text-to-SQL模型接入后端服务,验证自然语言转SQL功能 ,目前仍在权衡服务器部署与内网穿透方案。 | + + + +## 小结 + + + +1. **AI模型取得阶段性成果:** 本周完成了AI模型在Spider数据集上的微调与量化工作,准确率达到0.779; +2. **基础功能对接完毕:** 登录注册、用户设置等核心流程的前后端对接已稳定; +3. **文档规范化整改:** 团队对历史文档进行了系统性的交叉检查与整改,明确了修改说明(Change Log)的提交要求; +4. **部署方案待定:** 由于AutoDL平台限制,AI模型的测试运行环境(云端vs本地穿透)尚未敲定,需根据进一步测试结果决定; +5. **协作沟通高效:** 前端主动指出了等待后端接口的具体模块,跨模块沟通顺畅,明确了下一步联调重点。 + +------ + + + +## 【注】 + + + +1. **开发攻坚重点:** 前端需要完成已经开发稳定的接口对接,后端继续实现接口; +2. **AI调试方向:** 重点解决模型返回中文干扰SQL执行的问题,可通过拼接提示词或进一步微调解决; +3. **文档修改提醒:** 修改历史文档时务必在Change Log中写清楚“修改了什么”和“为什么修改”,便于助教审核。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/members/liangjunyao-weekly-plan-10.md b/doc/process/weekly/week-10/members/liangjunyao-weekly-plan-10.md new file mode 100644 index 0000000..07d5b24 --- /dev/null +++ b/doc/process/weekly/week-10/members/liangjunyao-weekly-plan-10.md @@ -0,0 +1,31 @@ +# 个人周计划-第10周 + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-11-24 +**结束时间**:2025-11-30 + +#### 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ---------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | 完善后端核心模块的CURD层 | 后端开发人员 | 【本周重点】 在现有API基础上,为用户管理、项目管理等核心模块补充完整的增删改查(CRUD)逻辑。确保所有接口支持分页、过滤、排序,并统一返回数据格式,为前端提供稳定、易用的数据接口。 | +| 2 | 设计并实现系统级异常处理机制 | 个人 | 【本周重点】 建立统一的全局异常处理器,捕获并标准化处理数据库错误、业务逻辑错误、参数校验失败等各类异常。通过日志记录关键错误信息,并向客户端返回清晰、友好的错误码与提示,提升系统健壮性与用户体验。 | +| 3 | 学习Redis在工程中的高级应用 | 个人 | 【技术预研】 系统性学习Redis作为缓存、会话存储、分布式锁、消息队列等场景的工程实践。重点研究如何利用Redis优化高频查询、实现限流或幂等控制,为后续性能优化做准备。 | +| 4 | 学习Celery异步任务框架 | 个人 | 【技术预研】 学习Celery的基本架构、任务定义、任务队列、结果存储及监控。探索其在本项目中的应用场景,如将邮件发送、文件处理等耗时操作异步化,以提高接口响应速度和系统吞吐量。 | +| 5 | 配合前后端联调测试 | 前端开发人员 | 根据团队计划,积极参与前后端接口联调,验证新增的CURD功能和异常处理逻辑是否符合预期,及时修复联调中发现的问题。 | +| 6 | 代码规范性自查 | 个人 | 【周五前完成】 对本周新编写的代码进行自查,确保命名规范、注释完整、结构清晰,为下周的团队互评做好准备。 | + +#### 小结 + +**核心任务安排**: +本周工作重心是夯实后端基础能力,重点在于**完善核心模块的CURD层的功能**和**构建健壮的系统异常处理机制**。这两项工作直接关系到Alpha版本的可用性和稳定性,是当前阶段最优先的任务。 + +**技术预研方向**: +在完成核心开发任务的同时,将投入精力**深入学习Redis和Celery的工程化用法**。这不仅是为了解决当前可能存在的性能瓶颈(如邮件发送延迟),更是为了提前规划系统的可扩展性和可靠性,为后续的Beta版本迭代打下坚实的技术储备。 + +**风险控制**: +技术预研(Redis/Celery)需注意避免“过度设计”,应聚焦于解决当前或近期可预见的实际问题。同时,在完善CURD和异常处理时,要充分考虑边界情况和并发场景,编写详尽的单元测试,确保代码质量。 + +**与团队协作**: +紧密配合团队的V1.0后端功能开发与前后端联调计划,确保个人进度与团队节奏同步。在代码规范性自查方面,也将为团队整体代码质量的提升贡献力量。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/members/liangjunyao-weekly-summary-10.md b/doc/process/weekly/week-10/members/liangjunyao-weekly-summary-10.md new file mode 100644 index 0000000..ffd3515 --- /dev/null +++ b/doc/process/weekly/week-10/members/liangjunyao-weekly-summary-10.md @@ -0,0 +1,27 @@ +# 个人周总结-第10周 + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-11-24 +**结束时间**:2025-11-30 + +## 本周任务完成情况 + +| 序号 | 计划内容 | 是否完成 | 情况说明 | +| ---- | ---------------------------- | -------- | ------------------------------------------------------------ | +| 1 | 完善后端核心模块的CURD层 | 完成 | 已完成用户管理、项目管理等核心模块的增删改查逻辑,实现分页、过滤、排序功能,统一了数据返回格式,为前端提供了稳定的数据接口。 | +| 2 | 设计并实现系统级异常处理机制 | 完成 | 成功建立全局异常处理器,实现了对数据库错误、业务逻辑错误、参数校验失败等异常的标准化处理,完善了错误码体系,提升了系统健壮性。 | +| 3 | 学习Redis在工程中的高级应用 | 进行中 | 系统性学习了Redis作为缓存、分布式锁等场景的应用,为下周引入Redis优化系统性能做好技术储备。 | +| 4 | 学习Celery异步任务框架 | 进行中 | 掌握了Celery的基本架构和应用场景,完成了本地环境搭建,正在设计邮件发送等任务的异步化方案。 | +| 5 | 配合前后端联调测试 | 完成 | 积极参与前后端接口联调,验证了CURD功能和异常处理机制,解决了数据格式和接口一致性问题,确保核心流程稳定。 | +| 6 | 代码规范性自查 | 完成 | 对本周新增代码进行了全面自查,优化了命名规范、注释完整性和代码结构,确保符合团队编码标准。 | + +## 小结 + +**核心成果**:完成后端核心 CURD 层与全局异常处理机制,顺利推进前后端联调,为 α 版本验收筑牢基础。 + +**协作沟通**:与前端高效配合解决接口问题,为 UML 图表修订提供后端专业意见,团队协作顺畅。 + +**技术与风险**:完成 Redis、Celery 技术预研,采用渐进式策略验证新技术,规避 α 版本验收风险。 + +**下周重点**:应用 Redis/Celery 优化系统性能,配合完成 α 版本验收,针对性优化 BO-4 响应时间指标。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/members/liguolin-weekly-plan-10.md b/doc/process/weekly/week-10/members/liguolin-weekly-plan-10.md new file mode 100644 index 0000000..33c4279 --- /dev/null +++ b/doc/process/weekly/week-10/members/liguolin-weekly-plan-10.md @@ -0,0 +1,28 @@ +# 个人周计划-第10周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-11-24 + +结束时间:2025-11-30 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | ------------------------------ | -------------- | ------------------------------------------------------------ | +| 1 | **α版本前端功能收尾与API接入** | 后端开发人员 | **【本周重点】** 移除第9周编写的Mock模拟数据,编写API层逻辑,正式接入后端真实接口。重点完成:
1. 登录/注册流程的闭环;
2. 项目创建与列表加载的真实数据渲染;
3. 核心对话界面的流式响应对接。 | +| 2 | **AI模型微调监控与服务化封装** | AI负责人员 | **【核心攻坚】** 负责监控在租用的 **2\*4090** 显卡上进行的 **CodeLlama (Bird数据集)** 微调训练过程,分析Loss曲线防止过拟合。训练完成后,将其封装为内部推理API,供后端调用进行集成测试。 | +| 3 | **前后端接口通信** | 前后端开发人员 | 在前端接入真实接口后,与后端进行接口测试。重点解决跨域问题、数据格式差异以及对话响应的延迟问题,确保Alpha版本演示流畅。 | +| 4 | **代码规范性互评与整改** | 全体人员 | 参与跨小组代码互评活动,依据检查表评审他组代码。同时,根据互评反馈和自查结果,对自己负责的前端组件及AI微调脚本进行规范化整改(注释、命名、异常处理)。 | +| 5 | **UML图表修正与考核复习** | 全体成员 | 1. 根据最终代码实现,修正我负责的**系统顺序图(Sequence Diagram)**,确保“图码一致”;
2. 复习UML建模理论,针对类图、状态图绘制进行练习,准备即将到来的技能考核。 | +| 6 | **整理个人笔记与撰写周报** | 个人 | 整理本周在AI微调参数调整、前后端中的技术笔记,撰写个人周总结。 | + +## 小结 + +1. **决战Alpha交付**:本周是Alpha版本开发的最后冲刺周。我将全力完成前端从“UI展示”到“功能落地”的转变,彻底替换模拟数据,确保登录、项目管理和对话功能在真实环境下运行无误。 +2. **AI落地关键期**:作为AI方向负责人,本周需完成从**模型训练**到**模型推理**的跨越。确保CodeLlama模型在4090显卡上微调成功,并能输出符合预期的SQL语句,为系统核心功能提供支撑。 +3. **质量与考核并重**:在赶进度的同时,不忽视代码质量,认真对待代码互评环节。同时,利用碎片时间复习UML知识,确保顺利通过技能考核,并完成最终的设计文档修正。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/members/liguolin-weekly-summary-10.md b/doc/process/weekly/week-10/members/liguolin-weekly-summary-10.md new file mode 100644 index 0000000..5fc83ca --- /dev/null +++ b/doc/process/weekly/week-10/members/liguolin-weekly-summary-10.md @@ -0,0 +1,32 @@ +# 个人周总结-第10周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-11-24 + +结束时间:2025-11-30 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **完成情况** | **情况说明** | +| -------- | ------------------------ | ------------ | ------------------------------------------------------------ | +| 1 | **α版本前端功能** | **基本完成** | **登录注册、用户设置**模块已对接真实后端。核心对话界面已实现基础交互,报表查询和业务术语表板块因后端接口缺失部分接口,目前处于等待对接状态。 | +| 2 | **AI模型微调** | **基本完成** | **【核心攻坚】** 因平台扩容限制,目前选用体积更小的Spider数据集在租用的A800上微调8小时12分钟(之前的预估因为参数设置问题时间很长)完成了CodeLlama-13B模型的微调训练与评估。 | +| 3 | **本地部署微调后模型** | **进行中** | 由配置更好的同伴部署4bit量化后的gguf模型,限于平台不提供个人的公网域名,需要迁移部署微调后模型,目前为节省成本,部署在个人电脑使用内网穿透使用模型 | +| 3 | **前后端接口通信** | **进行中** | 配置后端运行环境,在Apifox上测试并改正后端写好的接口 | +| 4 | **代码规范性互评与整改** | **未开始** | 开发流程规定这周一公布互评名单,但未公布,故搁置,规范自身代码等待互查 | +| 5 | **UML图表修正** | **进行中** | 根据最终系统逻辑,**修正了系统顺序图(Sequence Diagram)**,确保了图码一致性。准备UML技能考核的复习。 | +| 6 | **整理个人笔记与周报** | **完成** | 整理了AI模型微调中的参数笔记,修复测试到的错误和异常接口,撰写了本总结。 | + + + +## 小结 + +1. **前端对接进展**:本周实现了从模拟数据到真实后端的跨越,登录与基础对话功能已跑通。目前的阻塞点在于后端报表与术语表接口的缺失,下周需催促后端尽快发布。 +2. **AI攻坚成果**:限于硬盘更换**Spider数据集**,并调整参数,使用单张**A800**显卡,耗时8小时完成了CodeLlama-13B的微调。 +3. **部署策略思考**:出于成本和网络环境的考量,目前部署在个人电脑使用内网穿透进行调用,后续将模型部署到服务器上 +4. **互评工作延后**:因互评名单未按时公布,原定的代码互评工作暂时搁置,目前主要集中精力在自查和优化自身代码规范上。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/members/liwentao-weekly-plan-10.md b/doc/process/weekly/week-10/members/liwentao-weekly-plan-10.md new file mode 100644 index 0000000..7d64283 --- /dev/null +++ b/doc/process/weekly/week-10/members/liwentao-weekly-plan-10.md @@ -0,0 +1,32 @@ +# 个人周计划-第10周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-11-24 + +**结束时间**:2025-11-30 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ----------------------- | ------------ | ------------------------------------------------------------ | +| 1 | α版本前端功能收尾 | 李果霖 | **[本周重点]** 本周是Alpha版本的冲刺周。需彻底移除项目中的模拟数据(Mock Data),编写完整的API适配层,完成登录注册、项目管理及基础NL部署与查询的最终逻辑开发,确保前端功能逻辑闭环。 | +| 2 | 前后端对接 | 后端开发人员 | **[本周重点]** 与后端紧密配合,打通前端页面与后端API的数据交互通道。重点解决上周联调中遗留的接口字段不一致问题,确保Alpha版本整体运行流畅,数据流转无误。 | +| 3 | 代码规范性互评与自查 | 全体成员 | 参与跨小组的代码审查活动,依据检查表评审他组代码并输出报告。同时,对自己负责的前端模块进行“自我体检”,优化变量命名和注释,确保交付代码符合规范。 | +| 4 | UML图表修正与一致性检查 | 全体成员 | 根据本周最终实现的Alpha版本代码,反向检查并修正之前的类图、顺序图和活动图,确保“图码一致”,避免设计文档与实际代码脱节。 | +| 5 | UML技能考核复习 | 个人 | 在推进开发的同时,利用碎片时间复习UML建模知识,重点练习类图和顺序图的绘制规范,为即将到来的技能考核做准备。 | +| 6 | 一周总结 | 个人 | 2025-11-30 整理Alpha版本最终的前端代码,汇总互评中发现的问题,复盘联调经验,参与制定小组下周计划。 | + + + +## 小结 + +**交付Alpha版:** 本周的核心任务是**交付Alpha版本**。对于前端而言,停止使用模拟数据,实现“登录注册+项目管理+基础NL查询”三大核心板块与后端的真实对接。 + +**质量控制:** 本周将进行严格的质量控制。对外,通过互评审查其他小组代码,汲取经验;对内,严格自查代码规范,并修正UML设计文档,确保文档准确反映代码实现的现状(图码一致)。 + +**备考与开发并行:** 在高强度的开发冲刺之余,还需分配精力准备UML技能考核,确保理论知识与实战能力同步提升。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/members/liwentao-weekly-summary-10.md b/doc/process/weekly/week-10/members/liwentao-weekly-summary-10.md new file mode 100644 index 0000000..2148d70 --- /dev/null +++ b/doc/process/weekly/week-10/members/liwentao-weekly-summary-10.md @@ -0,0 +1,40 @@ +# 个人周总结-第10周 + +## 姓名与起止时间 + +姓 名:李文韬 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-11-24 + +结束时间:2025-11-30 + + + +## 本周任务完成情况 + +| **序号** | **任务内容** | **是否完成** | **情况说明** | +| -------- | --------------------- | ------------ | ------------------------------------------------------------ | +| 1 | α版本前端功能开发 | 基本完成 | 登录注册及用户设置模块已移除模拟数据,逻辑开发完成;项目管理与NL查询部分因等待后端接口稳定,目前处于待对接状态。 | +| 2 | 前后端对接 | 进行中 | 完成了登录注册等基础功能的对接,数据交互流程已打通。目前主要受限于后端部分接口未就绪,联调工作仍在持续进行中。 | +| 3 | 代码规范性互评与自查 | 部分完成 | 完成了对自己负责的前端模块的代码规范自查。因课程组未按期公布互评名单,跨组代码审查工作暂时搁置。 | +| 4 | UML图表修正与考核复习 | 持续进行 | 根据当前代码实现调整了活动图;利用课余时间进行了UML技能考核的复习准备。 | +| 5 | 历史文档交叉校验 | 进行中 | **[新增任务]** 根据小组安排,启动了历史文档的交叉检查工作,重点关注格式规范和备注一致性。 | +| 6 | 一周总结 | 完成 | 2025-12-01 整理了本周开发产出,参与制定了小组下周计划,并完成了本周总结 | + +## 小结 + + + +1. **核心功能落地:** 本周完成了登录、注册及用户设置等核心模块的前端开发与真实接口对接,验证了系统基础框架的稳定性。 +2. **进度依赖阻塞:** 原计划完成的“项目管理”与“NL查询”功能,受限于后端API尚未完全稳定,导致前端只能完成界面逻辑,暂无法进行最终联调。 +3. **计划灵活调整:** 针对未公布互评名单的情况,及时调整工作重心,优先完成了自身代码的规范化整改,并增加了历史文档校验工作。 +4. **备考与开发兼顾:** 在处理开发任务的同时,持续推进UML图表修正与技能考核复习,确保设计文档与代码的一致性。 + + + +## 【注】 + +1. **下周重点:** 需密切关注后端接口进度,一旦接口更新,立即完成剩余模块的对接与联调。 +2. **文档提交:** 下周需重点完成所负责周次文档的校验修改,并注意在Change Log中详细记录修改内容。 diff --git a/doc/process/weekly/week-10/members/wanglirong-weekly-plan-10.md b/doc/process/weekly/week-10/members/wanglirong-weekly-plan-10.md new file mode 100644 index 0000000..841c1a3 --- /dev/null +++ b/doc/process/weekly/week-10/members/wanglirong-weekly-plan-10.md @@ -0,0 +1,39 @@ +# 个人周计划-第10周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-24 +**结束时间:** 2025-11-30 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | UML图表修正与完善 | 全体成员 | 根据代码实现情况,修正自然语言查询、会话管理等模块的顺序图,确保图码一致 | +| 2 | 代码规范性自查+互评 | 全体人员 | 首先自查,然后接收并审查其他小组代码,依据规范检查表评审,整理代码审查报告提交 | +| 3 | α版本后端功能收尾 | 后端开发人员 | **【本周重点】** 完成聊天模块、自然语言查询等核心功能开发,打通负责模块,确保功能逻辑完善 | +| 4 | 前后端对接联调 | 前后端开发人员 | **【本周重点】** 积极配合前端完成接口对接,移除模拟数据,接入真实接口,确保数据交互流畅 | +| 5 | AI模型接入测试 | AI负责人员 | 配合将微调后的Text-to-SQL模型接入后端服务,验证自然语言转SQL功能 | +| 6 | Mock模拟测试学习与应用 | 个人 | 学习使用Mock技术进行接口模拟测试,提高测试效率和代码质量 | +| 7 | 代码规范性自查优化 | 个人 | 结合互评标准,对负责模块代码进行优化,确保命名规范、注释完整、结构清晰 | +| 8 | UML技能考核准备 | 个人 | 复习UML建模知识,重点练习类图、顺序图等核心图表绘制规范 | + +## 小结 + +1. **核心功能收尾:** 本周重点完成负责的后端模块开发,确保聊天功能和自然语言查询功能完整可用; +2. **技术能力提升:** 学习掌握Mock模拟测试技术,提升代码测试和调试能力; +3. **质量保障:** 通过代码规范性自查和互评,提高代码质量,培养良好的编程习惯; +4. **知识储备:** 准备UML技能考核,巩固系统设计理论基础; +5. **团队协作:** 加强前后端对接配合,确保Alpha版本整体功能完整; +6. **希望获得的帮助:** 希望在Mock测试技术和AI模型集成方面获得更多实践指导。 + +--- + +## 【注】 + +1. **核心任务提醒:** 本周必须完成负责模块的核心功能开发,确保Alpha版本基础功能完备; +2. **技术学习重点:** Mock模拟测试技术学习需在本周内掌握并应用; +3. **时间节点把控:** 代码规范性互评和UML图表修正需按时完成; +4. **质量要求:** 代码自查和优化工作需认真完成,为代码互评做好准备。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/members/wanglirong-weekly-summary-10.md b/doc/process/weekly/week-10/members/wanglirong-weekly-summary-10.md new file mode 100644 index 0000000..e553156 --- /dev/null +++ b/doc/process/weekly/week-10/members/wanglirong-weekly-summary-10.md @@ -0,0 +1,48 @@ +# 个人周总结-第10周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-11-24 +**结束时间:** 2025-11-30 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | UML图表修正与完善 | 进行中 | 根据代码实现情况,修正了自然语言查询和会话管理模块的顺序图,其他图表修正持续进行中 | +| 2 | 代码规范性自查优化 | 完成 | 按照团队规范对负责模块代码进行了全面自查和优化,确保命名规范、注释完整 | +| 3 | α版本后端功能收尾 | 完成 | **【核心成果】** 完成了聊天模块、自然语言查询、会话管理三大核心功能的开发,功能逻辑完善 | +| 4 | 前后端对接联调 | 部分完成 | 积极配合前端进行接口对接,登录注册等基础功能已完成对接,会话管理等新增功能接口待前端接入 | +| 5 | AI模型接入测试 | 完成 | 配合AI负责人员完成微调后Text-to-SQL模型的接入测试,准确率达到0.779 | +| 6 | Mock模拟测试学习与应用 | 完成 | 学习了Mock技术的基本原理和应用方法,并在部分接口测试中进行了实践 | +| 7 | UML技能考核准备 | 进行中 | 复习了UML建模知识,重点关注类图和顺序图的绘制规范 | +| 8 | 代码规范性互评 | 未开始 | 由于互评名单未公布,暂未开始,已规范自身代码等待后续互查 | + +## 对团队工作的建议 + +1. **接口开发协调:** 建议后端开发人员加强沟通,尽快稳定所有接口,为前端对接提供支持; +2. **进度同步机制:** 建议建立更频繁的进度同步会议,及时发现和解决开发阻塞问题; +3. **技术文档完善:** 建议同步完善API接口文档,确保文档与实际实现一致; +4. **环境部署规划:** 建议明确AI模型的最终部署方案,为系统集成提供稳定环境。 + +## 小结 + +1. **核心功能完成:** 成功完成了聊天模块、自然语言查询和会话管理三大核心功能的开发,实现了α版本的关键技术目标; +2. **AI模型进展:** 成功配合完成AI模型微调和量化工作,准确率达到0.779,取得阶段性成果; +3. **基础功能稳定:** 用户设置、管理员功能、会话接口等核心模块已实现稳定运行; +4. **代码质量提升:** 通过代码规范性自查,提高了负责模块的代码质量和可维护性; +5. **技术学习收获:** 掌握了Mock模拟测试技术,提升了测试能力和开发效率; +6. **对接工作推进:** 与前端配合完成了基础功能接口对接,为后续全面联调奠定基础; +7. **待完善工作:** 部分新增功能接口需与前端进一步对接,确保功能完整可用; +8. **UML准备充分:** 认真复习UML知识,为技能考核做好充分准备。 + +--- + +## 【注】 + +1. **技术指导需求:** 希望在复杂接口设计和性能优化方面获得更多实践指导; +2. **协作效率提升:** 建议加强后端开发人员间的协作,加快接口开发进度; +3. **文档质量提醒:** 已按时完成个人总结提交,相关代码和文档已按要求维护; +4. **重点工作规划:** 下周将重点推进新增功能的接口对接和性能优化工作。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/members/yimuran-weekly-plan-10.md b/doc/process/weekly/week-10/members/yimuran-weekly-plan-10.md new file mode 100644 index 0000000..ef1aaef --- /dev/null +++ b/doc/process/weekly/week-10/members/yimuran-weekly-plan-10.md @@ -0,0 +1,33 @@ +# 个人周计划-第10周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-24 + +**结束时间:** 2025-11-30 + +## 本周核心目标 + +1. **前后端联调攻坚**:解决第九周遗留的数据对接不畅问题,打通前端与后端的真实数据交互。 +2. **设计文档修正**:根据最终代码实现,修正UML图表,确保图码一致。 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人** | **情况说明** | +| -------- | --------------------- | ----------------- | ------------------------------------------------------------ | +| 1 | **前后端通信功能** | 个人 (伊木然) | 接续上周进度,重点完成“项目管理”模块剩余接口(如创建项目、获取项目列表、删除项目)的开发,确保Alpha版本核心逻辑闭环。 | +| 2 | **前后端深度联调** | 个人 (与前端协作) | **【本周重点】** 与前端开发人员(李果霖、李文韬)紧密配合,解决上周遗留的接口数据对接问题,移除所有模拟数据,确保登录、注册及项目管理流程在真实环境下运行流畅。 | +| 3 | **代码自查与优化** | 个人 | 结合互评中发现的通用问题及团队标准,对自己编写的 FastAPI 代码(用户及项目模块)进行持续优化,确保变量命名、注释规范。 | +| 4 | **UML图表修正与定稿** | 个人 | 根据最终编写的代码逻辑,修正上周绘制的“用户管理”和“项目管理”相关的类图和顺序图,确保设计文档与代码实现完全一致。 | +| 5 | **UML技能考核准备** | 个人 | 抽出时间复习UML建模理论,针对类图、顺序图等核心图表进行专项练习,准备即将到来的技能考核。 | +| 6 | **文档管理与周报** | 个人 | 2025-11-30,整理本周的代码审查报告、修正后的UML图等文档,撰写个人及小组周总结。 | + +## 小结 + +1. **实现后端**:本周是Alpha版本实现周,必须确保“用户与项目管理”模块不仅代码写完,还要能通过前端跑通,这是本周工作的重中之重。 +2. **联调优先**:吸取上周教训,将把更多精力投入到与前端的联调中,主动沟通解决接口格式和数据传输问题。 +3. **质量与规范**:通过互评和自查双管齐下,提升代码质量;同时保证设计文档(UML)的准确性,做到“名实相符”。 \ No newline at end of file diff --git a/doc/process/weekly/week-10/members/yimuran-weekly-summary-10.md b/doc/process/weekly/week-10/members/yimuran-weekly-summary-10.md new file mode 100644 index 0000000..f0dd7fd --- /dev/null +++ b/doc/process/weekly/week-10/members/yimuran-weekly-summary-10.md @@ -0,0 +1,28 @@ +# 个人周总结-第10周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-11-24 + +**结束时间:** 2025-11-30 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **完成情况** | **情况说明** | +| -------- | ---------------------- | --------------- | ------------------------------------------------------------ | +| 1 | **Alpha 后端功能收尾** | **基本完成** | 负责的“用户与项目管理”模块(登录、注册、用户设置)API运行稳定,已具备对接条件。根据会议反馈,需配合拆分“公告功能”接口。 | +| 2 | **前后端深度联调** | **完成** | **【本周重点】** 与前端紧密配合,成功打通了登录、注册及用户设置的真实数据交互,移除了相关模块的模拟数据。 | +| 3 | **代码规范性互评** | **未开始** | 原定计划接收其他小组代码进行评审,但因课程组未按时公布互评名单,该任务暂时搁置,转为专注于自身代码的自查与规范。 | +| 4 | **UML图表修正与定稿** | **进行中** | 根据最终系统逻辑,**修正了系统类图**,确保了图码一致性。准备UML技能考核的复习。 | +| 5 | **小组文档修改与校验** | **新增/进行中** | 根据会议部署,认领了前两周的项目文档进行交叉检查,重点检查格式规范和备注一致性问题。 | +| 6 | **后端其他接口细调** | **进行中** | 对于管理员功能,项目管理,用户功能等模块已经完成了基本实现,对内容进行调整中 | + +## 小结 + +1. **联调里程碑**:本周最大的成果是实现了用户认证模块的前后端完全打通,Alpha 版本的基础地基已夯实,系统不再是“空中楼阁”。 +2. **计划外变动**:受外部因素影响,代码互评暂停,但新增了“历史文档全面整改”的任务。我快速调整了工作重心,投入到文档规范化工作中。 +3. **下周重点**:除了继续支持**前端联调**,下周的核心任务将是**UML技能考核的提交**以及**历史文档的最终归档**。 \ No newline at end of file diff --git a/doc/process/weekly/week-11/group/weekly-minutes-11.md b/doc/process/weekly/week-11/group/weekly-minutes-11.md new file mode 100644 index 0000000..ed850b7 --- /dev/null +++ b/doc/process/weekly/week-11/group/weekly-minutes-11.md @@ -0,0 +1,116 @@ +# 小组会议纪要-第11周 + +## 会议记录概要 + +**团队名称**: 3班-葫芦娃救bug + +**指导老师**: 李友焕 + +**主 持 人**: 王利蓉(项目经理) + +**记录人员**: 李文韬 + +**会议主题**: α版本验收准备、前后端联调问题攻坚、AI模型部署与Beta版预研 + +**会议地点**: 线上会议 + +**会议时间**: 2025-12-07 22:00-23:00 + +**记录时间**: 2025-12-08 20:30 + +**参与人员**: 全体成员 + +## 会议内容 + +### 1. α版本验收安排与准备 + +- **验收时间变更**: + - 原定于本周进行的验收因课程安排推迟。 + - **最终确认时间**:下周二(12月9日)中午。 +- **准备工作**: + - 全体成员必须预留时间出席,确保全员在场。 + - 需提前准备好演示环境、测试数据及相关文档,确保演示流程顺畅。 + +### 2. 开发进度汇报与联调情况 + +- **前端对接(李文韬、李果霖)**: + - **已完成**:项目管理、公告管理、会话管理、AI对话、业务术语管理等模块的接口对接。 + - **阻塞点**:报表与查询模块尚未完全对接;管理员模块部分对接完成,仍有剩余接口待联调。 +- **后端开发(梁峻耀、伊木然)**: + - **已完成**:报表功能、公告列表功能、AI部分接口对接。 + - **进行中**:SQL执行环节的测试与修改(梁峻耀负责)。重点在于确保生成的SQL能正确在目标库执行并返回结果。 +- **技术问题讨论**: + - **ddl重复问题**:前端(李果霖)指出解析返回数据时发现 ddl 字段内容重复,后端(王利蓉/AI组)确认存在该问题,后续将进行修改。 + - **业务术语拼接**:目前Prompt中尚未拼接业务术语库,暂时搁置,优先保证核心流程跑通。 + +### 3. AI模型部署与调试 + +- **部署状态**: + - 微调后的AI模型已成功部署至云服务器,解决了Mac环境下无法通过Radmin连接的问题,目前运行稳定。 +- **存在问题**: + - **格式问题**:模型返回的SQL语句格式有时不规范。 + - **超时问题**:接口调用存在超时现象,需优化调用链路。 + - **中文干扰**:开源模型输出有时包含多余汉字,干扰SQL执行。 + +### 4. 下一阶段(Beta版)技术预研 + +- **架构调整规划(伊木然)**: + - 针对下一版本(第二阶段)的需求,明确了技术预研方向。 + - **核心任务**:研究“Schema生成”与“DDL生成”分离的实现方案,为系统架构升级做准备。 + +### 5. 文档与代码规范 + +- **文档工作**: + - 第11周文档校验已由李文韬完成。 + - API接口文档已初步定稿,涵盖认证、用户设置、消息管理等模块。 +- **代码自查**: + - 提议全体成员在空余时间进行代码规范性检查,清理冗余代码,但若与核心功能修复冲突,优先保证功能修复。 + +## 后续任务安排 + +### (1)项目开发重点: + +- **全力保验收**:本周二(12月9日)的α版本验收是重中之重。 +- **接口冲刺**:前端需完成报表、查询及管理员模块剩余接口的对接;后端需解决SQL执行的Bug。 +- **AI攻坚**:解决格式不规范及超时问题,提升系统响应稳定性。 + +### (2)具体分工确认: + +- **α版本验收**:全体成员 +- **前端对接**:李文韬、李果霖 +- **后端执行测试**:梁峻耀 +- **AI调试与微调**:王利蓉、李文韬 +- **Beta版预研**:伊木然 +- **周计划制定**:伊木然 +- **会议纪要**:李文韬 + +### (3)会议安排: + +- 下周例会时间:12月14日 星期日 晚上 + +## 问题总结 + +### 已解决问题: + +- **UML考核**:全员通过考核并按时提交了文档。 +- **AI环境适配**:通过云服务器部署解决了本地环境连接失败的问题。 +- **基础接口**:登录、会话、公告等核心功能已完成前后端对接。 + +### 待解决问题: + +- **功能短板**:AI的格式规范性和接口超时问题是目前演示的问题。 +- **联调缺口**:报表和查询模块的Mock数据尚未完全消除,需尽快完成真实接口对接。 +- **数据重复Bug**:需修复接口返回数据中字段重复的问题。 + +## 小组协作情况总结 + +**协作情况**: 本周团队协作高效,前端及时发现了接口数据重复的问题并反馈给后端。面对验收时间的调整,团队迅速调整了计划,将重心转移到演示环境准备和核心Bug修复上。文档检查工作按轮换机制有序进行。 + +## 一周纪律情况总结 + +**纪律情况**: 会议全员准时参加,讨论氛围热烈。针对验收演示,全员均表示将预留时间配合。 + +## 备注 + +- **验收提醒**:周二验收时,请大家确保网络环境良好,演示流程需提前演练一遍。 +- **文档交接**:下周周计划由伊木然负责,请根据本周验收情况及时调整计划内容。 \ No newline at end of file diff --git a/doc/process/weekly/week-11/group/weekly-plan-11.md b/doc/process/weekly/week-11/group/weekly-plan-11.md new file mode 100644 index 0000000..9925211 --- /dev/null +++ b/doc/process/weekly/week-11/group/weekly-plan-11.md @@ -0,0 +1,42 @@ +# 小组周计划-第11周 + +## 团队名称和起止时间 + +**团队名称**: 3班-葫芦娃救bug +**开始时间**: 2025-12-01 +**结束时间**: 2025-12-07 + + + +**本周任务计划安排** + +| 序号 | 计划内容 | 执行人 | 情况说明 | +| ---- | ------------------ | ------------ | ------------------------------------------------------------ | +| 1 | UML技能考核 | 全体成员 | 对UML课程知识进行复习,参考团队项目UML。按要求完成UML技能考核 | +| 2 | UML图提交 | 全体成员 | 周五晚上十点前完成UML图提交,确保符合规范要求 | +| 3 | 小组文档校验修改 | 相关成员 | 伊木然负责4、5周;梁峻耀负责6、7周;王利蓉负责8、9周;李果霖负责10周;李文韬负责11周。需在周五前完成修改并提交 | +| 4 | 教师验收α版本 | 全体成员 | 准备教师对α版本的验收工作,确保系统功能完备、运行稳定 | +| 5 | 后端功能开发 | 后端开发人员 | 汇报进度,剩余部分分工开发:1 完成报表功能;2 实现普通用户获取公告列表功能(报表和业务术语接口需完善);3 完成AI部分接口对接 | +| 6 | 前端功能开发 | 前端开发人员 | 等待后端接口稳定后进行对接开发,完成相应前端功能实现 | +| 7 | API接口文档更新 | 全体开发人员 | 更新API接口文档,重点完善认证与授权、用户设置、管理员功能、对话与消息管理、会话管理等稳定接口的文档说明 | +| 8 | 微调后的AI模型部署 | AI负责人员 | 确定部署环境(服务器或内网穿透),考虑mac环境适配问题,推进模型优化工作 | + + + +## 小结 + +**α版本验收准备**: 本周重点准备教师对α版本的验收工作,确保系统功能完备、运行流畅; +**文档完善工作**: 按时完成UML图提交和各周次文档校验修改任务,确保文档质量; +**功能开发推进**: 后端重点完成报表功能、普通用户获取公告列表功能,解决AI部分接口对接问题; +**接口稳定性**: 更新API接口文档,明确各模块稳定接口,为前端开发提供可靠支持; +**质量保障**: 前端等待后端接口稳定后进行对接开发,确保系统整体质量和用户体验; +**环境准备**: 确定AI模型微调环境,为后续功能优化做好准备。 + + + +## 【注】 + +**UML考核与提交**: UML技能考核和UML图需在12月5日(周五)晚10:00前完成提交; +**文档校验**: 各成员需按分工完成对应周次的文档校验修改工作; +**验收准备**: 全体成员需做好α版本验收准备,确保系统功能完整、运行稳定; +**接口对接**: 前后端开发人员需密切配合,确保接口对接顺利进行。 \ No newline at end of file diff --git a/doc/process/weekly/week-11/group/weekly-summary-11.md b/doc/process/weekly/week-11/group/weekly-summary-11.md new file mode 100644 index 0000000..b8f77b9 --- /dev/null +++ b/doc/process/weekly/week-11/group/weekly-summary-11.md @@ -0,0 +1,39 @@ +# 小组周总结-第11周 + +## 团队名称和起止时间 +**团队名称**:3班-葫芦娃救bug + +**开始时间:** 2025-12-01 + +**结束时间:** 2025-12-07 + + + +## 本周任务完成情况 +| 序号 | 计划内容 | 是否完成 | 执行情况 | +| ---- | ------------------ | --------------------- | ------------------------------------------------------------ | +| 1 | UML技能考核 | 已完成 | 全体成员已完成UML技能考核,对UML课程知识进行了复习并参考团队项目UML完成考核 | +| 2 | UML图提交 | 已完成 | 全体成员按规范要求完成了UML图设计,并于周五晚10点前成功提交 | +| 3 | 小组文档校验修改 | 已完成 | 伊木然(4、5周)、梁峻耀(6、7周)、王利蓉(8、9周)、李果霖(10周)、李文韬(11周)均按分工完成对应周次文档校验修改工作 | +| 4 | 教师验收α版本 | 延期进行 | 原计划本周验收,因教师时间安排推迟至下周二(12月9日)中午,已与指导老师确认时间 | +| 5 | 后端功能开发 | 大部分完成 部分进行中 | 报表功能开发完成、普通用户获取公告列表功能完成、AI部分接口对接完成、SQL语句执行部分正在对接中 | +| 6 | 前端功能开发 | 已完成 | 已完成项目管理、公告管理、会话管理、AI对话、业务术语管理等模块的接口对接与功能实现,为配合后端接口的对页面进行了调整。 | +| 7 | API接口文档更新 | 已初步定稿 | API接口文档已初步定稿,涵盖认证与授权、用户设置、管理员功能、对话与消息管理、会话管理等模块 | +| 8 | 微调后的AI模型部署 | 已完成 | AI模型已成功部署至云服务器,解决了mac环境不能通过radmin连接AI模型的问题,模型运行稳定 | + + + +## 小结 + +- **文档工作全面完成**:UML考核、UML图提交及各周次文档校验修改工作均按计划完成,文档质量得到提升; +- **α版本验收准备就绪**:系统功能已基本完成,教师验收时间调整至下周二中午,团队已做好充分准备; +- **后端开发进展顺利**:报表功能、公告列表功能和AI接口对接已全部完成,SQL语句执行部分正在紧锣密鼓对接中; +- **前端对接效果显著**:项目管理、公告管理、会话管理、AI对话、业务术语管理等核心模块已完成接口对接,用户体验良好; +- **AI部署取得突破**:微调后的AI模型成功部署到云服务器,解决了环境适配问题,为系统智能化功能提供了坚实基础; +- **API文档趋于完善**:接口文档已初步定稿,为前后端协作和后续维护提供了清晰的技术参考。 + +## 【备注】 +- **α版本验收**:请全体成员于下周二(12月9日)中午做好验收准备,确保系统功能完整、运行稳定; +- **SQL对接**:后端开发人员需加快SQL语句执行部分的对接工作,确保功能完整性; +- **文档完善**:API接口文档虽已初步定稿,但需根据实际使用情况持续优化完善; +- **系统测试**:在教师验收前,建议团队进行一轮全面的功能和压力测试,确保系统稳定性。 \ No newline at end of file diff --git a/doc/process/weekly/week-11/members/liangjunyao-weekly-plan-11.md b/doc/process/weekly/week-11/members/liangjunyao-weekly-plan-11.md new file mode 100644 index 0000000..b3508e8 --- /dev/null +++ b/doc/process/weekly/week-11/members/liangjunyao-weekly-plan-11.md @@ -0,0 +1,29 @@ +# 个人周计划-第11周 + +**姓  名**:梁峻耀 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-12-01 + +**结束时间**:2025-12-07 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ----------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **Redis 缓存机制引入** | 个人 | 【核心任务】在做好完整系统备份后,为高频访问数据添加 Redis 缓存层:实现查询结果缓存,特别是对重复的自然语言查询;为业务术语库添加缓存,提升 AI 理解响应速度;为系统公告和用户配置信息配置缓存策略,减少数据库负载 设置合理的缓存过期策略,确保数据一致性。当然这些不一定合理,提升的性能可能有限,在简单实现后可以先进行测试看值不值得完整实现。 | +| 2 | **Celery 异步任务实现** | 个人 | 【核心任务】将耗时操作改造为异步任务,提升用户体验: 将数据库自动部署流程重构为 Celery 任务,添加进度状态回调,实现报表导出功能的异步处理,支持大文件生成,将邮件通知(如修改邮箱验证)移至后台队列处理,为审计日志记录添加异步写入机制,减少主流程等待时间 | +| 3 | **系统性能优化与测试** | 个人 | 【关键支持】结合 α 版本验收要求: 为关键接口添加性能监控埋点,验证 Redis 与 Celery 引入后对系统响应时间的影响,编写缓存失效和任务重试的测试用例,确保优化不影响原有功能,准备回滚方案以防更改失败。 | +| 4 | **小组文档校验修改** | 全体成员 | 【配合小组任务】按照周计划安排,我负责检查第 6、7 周的项目文档 | +| 5 | **API 接口文档更新** | 全体开发人员 | 【配合小组任务】更新受影响的 API 文档: 更新API接口文档,重点完善认证与授权、用户设置、管理员功能、对话与消息管理、会话管理等稳定接口的文档说明 | +| 6 | **α 版本验收准备** | 全体成员 | 【配合小组任务】确保优化工作不影响验收: 创建完整系统备份和快照 准备演示环境,验证所有核心功能;参与 UML 技能考核和 UML 图提交(周五晚 10 点前) | +| 7 | **代码规范与注释完善** | 个人 | 【质量保障】对新增代码进行规范与注释: 遵循团队编码规范,确保 Redis/Celery 代码可读性 为缓存策略和异步任务添加详细注释 记录性能改进前后的关键指标对比 | + +## 小结 + +**核心优化方向**:聚焦 Redis 和 Celery 优化数据库部署、自然语言查询、报表导出等关键功能,优化响应时间。 + +**风险控制**:修改前备份(要进行α版本验收了,得有可以运行的版本)、渐进式部署、备好回滚预案、监控 Redis 与 Celery 资源。 + +**协同重点**:协调前端异步状态接口、支持 AI 模型调用、配合 α 版本验收与 UML 提交、符合非功能需求规范。 diff --git a/doc/process/weekly/week-11/members/liangjunyao-weekly-summary-11.md b/doc/process/weekly/week-11/members/liangjunyao-weekly-summary-11.md new file mode 100644 index 0000000..a41eb47 --- /dev/null +++ b/doc/process/weekly/week-11/members/liangjunyao-weekly-summary-11.md @@ -0,0 +1,35 @@ +# **个人周总结 - 第11周** + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-12-01 +**结束时间**:2025-12-07 + +## 本周任务完成情况 + +| 序号 | 计划内容 | 是否完成 | 情况说明 | +| ---- | ------------------- | -------- | ------------------------------------------------------------ | +| 1 | Redis 缓存机制引入 | 进行中 | 已成功为**系统公告**模块实现 Redis 缓存,初步验证缓存读写逻辑正常,但尚未与前端完成联调;**业务术语库**的缓存支持已初步编码,但尚未跑通,正在排查数据结构与缓存更新策略问题。 | +| 2 | Celery 异步任务实现 | 未完成 | 尚未成功将任何耗时操作迁移至 Celery;在 Docker 容器中运行的 Celery Worker **频繁崩溃重启**,初步怀疑与 Redis 连接配置、任务序列化方式或资源限制有关,正在深入排查日志与环境配置。 | +| 3 | 系统性能优化与测试 | 未开展 | 因 Redis 仅部分落地、Celery 尚未稳定运行,**暂未开展系统性性能测试**;计划待核心优化完成后补充埋点与压测。 | +| 4 | 小组文档校验修改 | 完成 | 按分工完成第 6、7 周项目文档的校验与修订,确保内容一致性与规范性。 | +| 5 | API 接口文档更新 | 完成 | 配合团队更新认证、用户设置、管理员功能等模块的接口文档,确保与当前实现一致。 | +| 6 | α 版本验收准备 | 完成 | 参与 UML 技能考核与 UML 图提交,创建系统备份与演示环境,确保核心功能可演示,为下周二(12月9日)教师验收做好准备。 | +| 7 | 代码规范与注释完善 | 部分完成 | 对已实现的 Redis 缓存代码补充了注释与策略说明;Celery 相关代码因尚未稳定,暂未完善注释。 | + +## 小结 + +**核心进展**: + +- 成功落地**系统公告的 Redis 缓存**(但还未与前端开始测试),为高频读取场景提供性能优化基础; +- 按时完成**文档校验、API 更新与验收准备**,保障团队 α 版本交付节奏。 + +**协作沟通**: + +- 积极参与小组任务协调,确保文档与接口规范同步; +- 与前端保持沟通,计划下周联调公告缓存接口。 + +**技术与风险**: +- **Celery 稳定性问题**成为本周主要技术阻塞,需优先解决容器内崩溃问题; +- Redis 缓存尚未覆盖核心场景(如业务术语库、自然语言查询),**性能提升效果有限**; +- 采取**保守策略**:保留可运行的主干版本,所有优化均在分支验证,确保不影响 α 版本验收。 diff --git a/doc/process/weekly/week-11/members/liguolin-weekly-plan-11.md b/doc/process/weekly/week-11/members/liguolin-weekly-plan-11.md new file mode 100644 index 0000000..2e5e29f --- /dev/null +++ b/doc/process/weekly/week-11/members/liguolin-weekly-plan-11.md @@ -0,0 +1,28 @@ +# 个人周计划-第11周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-12-01 + +结束时间:2025-12-07 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | -------------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **后端API接口对接与联调** | 后端开发人员 | **【主动推进】** 不被动等待,主动配合后端完成剩余核心接口(报表查询、业务术语表、公告列表)的对接工作; | +| 2 | **小组文档校验与修改(第10周)** | 项目经理 | 根据分配任务,**负责检查与修正第10周的项目文档**(周计划、周总结、会议纪要)。重点检查格式规范及备注一致性,确保于周五前提交。 | +| 3 | **AI模型部署与输出优化** | AI负责人员 | 1.等待服务器硬盘可扩容后,用Bird数据集开启第二轮微调;
2. 最终确定部署方案(解决内网穿透稳定性或适配服务器环境),实现AI服务稳定在线。 | +| 4 | **UML技能考核与图表提交** | 全体成员 | 1. 复习UML理论知识,准备技能考核;
2. 检查并最终确认自己负责的系统顺序图,导出规范PNG,合力编写UML文档,于周五晚10点前完成提交。 | +| 5 | **α版本验收准备** | 全体成员 | 对已完成的前端功能(登录、设置、对话、报表)进行全链路自测,确保系统无明显Bug,为教师验收做好演示准备。 | +| 6 | **API接口文档更新** | 全体开发人员 | 协助后端完善API文档中关于前端调用参数的说明,确保文档与代码实际实现保持一致。 | + +## 小结 + +1. **接口对接冲刺**:本周是Alpha版本验收前的最后一周,核心任务是**打通最后几公里**,将报表和公告等剩余功能模块与后端接口完全对接,消除所有Mock数据。 +2. **AI服务落地**:重点解决模型输出格式问题(Format Issues),确保AI不仅仅是“能跑通”,而是“能用”,并确定最终的部署环境以供演示使用。 +3. **文档与考核**:负责好第10周文档的质量把关;同时确保UML考核顺利通过,不拖团队后腿。 \ No newline at end of file diff --git a/doc/process/weekly/week-11/members/liguolin-weekly-summary-11.md b/doc/process/weekly/week-11/members/liguolin-weekly-summary-11.md new file mode 100644 index 0000000..bb625ea --- /dev/null +++ b/doc/process/weekly/week-11/members/liguolin-weekly-summary-11.md @@ -0,0 +1,28 @@ +# 个人周总结-第11周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-12-01 + +结束时间:2025-12-07 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **完成情况** | **情况说明** | +| -------- | -------------------------- | -------------- | ------------------------------------------------------------ | +| 1 | **前端核心功能对接** | **已完成** | **项目管理、公告管理、会话管理、AI对话、业务术语管理**等核心模块已完成与后端接口的对接与功能实现。相比上周,消除了绝大部分Mock数据,实现了全流程跑通。 | +| 2 | **小组文档校验(第10周)** | **已完成** | 按分工完成了**第10周**项目文档(周计划、周总结等)的校验与修改工作,修正了部分错别字,确保了文档的一致性。 | +| 3 | **AI模型云端部署** | **已完成** | **【重大突破】** 成功将微调后的CodeLlama-13B模型部署至**云服务器**,解决了Mac环境无法通过Radmin连接本地模型的问题,确保了模型运行的稳定性,不再依赖个人电脑内网穿透。 | +| 4 | **UML技能考核与提交** | **已完成** | 复习了UML课程知识,顺利通过了团队项目UML考核;按规范导出了系统顺序图并于周五晚10点前完成了最终提交。 | +| 5 | **α版本验收准备** | **延期进行** | 已将前后端对接后的系统自测完毕,具备演示条件。原计划本周验收,因教师时间安排推迟至**下周二(12月9日)中午**。 | +| 6 | **API接口文档更新** | **已初步定稿** | 配合后端完成了API接口文档的更新与定稿,重点完善了后端API测试报错和漏洞,在Apifox上导出API接口文档为前后端协作提供了清晰依据。 | + +## 小结 + +1. **前端对接全面落地**:本周完成了从“部分对接”到**“核心功能全覆盖”**的转变,公告、术语及AI对话等关键模块均已联调成功,用户体验流畅,达到了Alpha版本交付标准。 +2. **AI服务云端化**:成功解决了环境适配难题,废弃了上周不稳定的“本地部署+内网穿透”方案,实现了模型的**云端部署**,为系统智能化功能提供了坚实、稳定的基础。 +3. **文档与考核收官**:顺利完成UML技能考核及图表提交,同时完成了第10周文档的校验工作,确保了过程文档的准确性和完整性。 \ No newline at end of file diff --git a/doc/process/weekly/week-11/members/liwentao-weekly-plan-11.md b/doc/process/weekly/week-11/members/liwentao-weekly-plan-11.md new file mode 100644 index 0000000..156b403 --- /dev/null +++ b/doc/process/weekly/week-11/members/liwentao-weekly-plan-11.md @@ -0,0 +1,32 @@ +# 个人周计划-第11周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-12-01 + +**结束时间**:2025-12-07 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | UML技能考核与图表提交 | 全体成员 | **[本周重点]** 复习UML课程知识进行备考。在**周五(12月5日)晚10:00前**完成UML图的提交,确保图表符合规范要求,并参考技能考核 。 | +| 2 | α版本验收准备 | 全体成员 | **[本周重点]** 准备α版本的验收工作。作为前端开发,需对现有功能(登录、项目管理等)进行全流程自测,修复演示中可能出现的交互Bug,确保系统运行稳定流畅 。 | +| 3 | 小组文档校验(第11周) | 个人 | **[个人专项]** 根据小组分工,**我负责第11周的文档校验修改工作**。需仔细核对本周产出的文档质量,并在周五前完成修改与提交 。 | +| 4 | 前端新功能开发 | 后端开发人员 | 密切关注后端进度,待接口稳定后,与后端进行对接,并配合调试AI部分的接口对接 。 | +| 5 | API接口文档更新 | 全体开发人员 | 配合后端更新API接口文档,重点完善用户设置、管理员功能、对话与消息管理等模块的接口说明,确保前端调用参数与文档定义保持一致 。 | +| 6 | 一周总结 | 个人 | 2025-12-07 汇总α版本验收情况,整理UML考核结果,确认新功能(报表/公告)的开发进度,参与制定小组下周计划 。 | + + + +## 小结 + +**验收与考核并行:** 本周是α版本的“大考”之周,核心任务是确保系统在教师验收时**功能完备、运行稳定** 。同时,必须保证**UML技能考核**的顺利通过及图表的按时提交,这是本周不可逾越的硬性指标 。 + +**文档专项责任:** 根据团队分工,本周我需承担**第11周文档的校验与修改**任务,需严把质量关,确保文档无误后按时提交 。 + +**开发把控:** 新功能开发需与后端保持高频沟通,一旦接口就绪立即投入开发,同时同步更新API文档,为后续的Beta版本打好基础 。 \ No newline at end of file diff --git a/doc/process/weekly/week-11/members/liwentao-weekly-summary-11.md b/doc/process/weekly/week-11/members/liwentao-weekly-summary-11.md new file mode 100644 index 0000000..f152b57 --- /dev/null +++ b/doc/process/weekly/week-11/members/liwentao-weekly-summary-11.md @@ -0,0 +1,35 @@ +# 个人周总结-第11周 + +## 姓名与起止时间 + +姓 名:李文韬 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-12-01 + +结束时间:2025-12-07 + +## 本周任务完成情况 + +| **序号** | **任务内容** | **是否完成** | **情况说明** | +| -------- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | UML技能考核与图表提交 | 已完成 | 全体成员已完成UML技能考核,并按规范要求完成了UML图设计,于周五晚10点前成功提交。 | +| 2 | α版本验收 | 延期完成 | 完成了对自己负责的前端功能的全流程自测。原计划本周验收,因课程安排推迟至下周二(12月9日)中午,目前已做好演示准备。 | +| 3 | 前端功能开发与对接 | 已完成 | 完成了项目管理、公告管理、会话管理、AI对话等模块的接口对接与功能实现,并根据后端接口调整了页面逻辑。 | +| 4 | 小组文档校验(第11周) | 已完成 | **[个人专项]** 按照小组分工,完成了第11周小组文档的校验与修改工作,确保了文档质量。 | +| 5 | API接口文档更新 | 已初步定稿 | 配合团队完成了API接口文档的初步定稿,涵盖了用户设置、管理员功能及对话管理等模块,确保前端调用与文档一致。 | +| 6 | 微调后的AI模型部署 | 已完成 | AI模型已成功部署至云服务器,解决了mac环境不能通过radmin连接AI模型的问题,模型运行稳定。 | +| 7 | 一周总结 | 已经完成 | 汇总了α版本准备情况,整理了UML考核结果,并完成了本周总结。 | + +## 小结 + +1. **考核任务达标:** 本周顺利完成了UML技能考核与图表提交。 +2. **前端对接:** 完成了AI对话、公告管理等核心业务的前端对接工作,配合后端解决了部分接口适配问题,完善系统交互。 +3. **验收计划调整:** 针对α版本验收延期至下周二的情况,利用这段时间对系统进行了更细致的测试,确保演示时的稳定性。 +4. **文档职责落实:** 严格执行了小组分工,完成了第11周的文档校验工作,保证了项目文档的规范性和准确性。 + +## 【注】 + +1. **下周重点:** 全力应对下周二(12月9日)中午的α版本验收,确保演示过程无Bug。 +2. **测试建议:** 在验收前建议配合团队进行一次全面的功能测试,特别是针对AI模型交互部分的稳定性。 diff --git a/doc/process/weekly/week-11/members/wanglirong-weekly-plan-11.md b/doc/process/weekly/week-11/members/wanglirong-weekly-plan-11.md new file mode 100644 index 0000000..57f9afc --- /dev/null +++ b/doc/process/weekly/week-11/members/wanglirong-weekly-plan-11.md @@ -0,0 +1,41 @@ +# 个人周计划-第11周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-12-01 +**结束时间:** 2025-12-07 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | UML技能考核准备与参加 | 全体成员 | 复习UML课程知识,参考团队项目UML图,按要求完成UML技能考核 | +| 2 | 小组文档校验修改(第8、9周) | 个人 | 按照分工负责第8周、第9周小组文档的校验和修改工作,确保文档质量 | +| 3 | **后端报表功能开发** | 个人 | **【本周重点】** 独立完成报表功能后端实现,包括数据统计、分析结果生成等核心功能 | +| 4 | **普通用户获取公告列表功能** | 个人 | **【本周重点】** 独立实现普通用户获取公告列表功能,包括接口设计、业务逻辑实现和数据返回 | +| 5 | AI接口对接完善 | AI负责人员 | 配合完成AI部分接口对接工作,确保自然语言转SQL功能稳定可用 | +| 6 | API接口文档更新 | 个人 | 更新报表功能和公告列表功能的API接口文档,确保文档与实际实现一致 | +| 7 | α版本验收准备工作 | 全体成员 | 准备α版本验收,确保负责的报表功能和公告功能完整、运行稳定 | +| 8 | 微调后的AI模型部署支持 | AI负责人员 | 协助确定AI模型部署环境,解决可能的环境适配问题 | + +## 小结 + +1. **核心功能开发:** 本周重点独立完成报表功能和普通用户公告列表功能的后端开发工作; +2. **文档质量保障:** 认真完成第8、9周文档的校验修改工作,确保文档内容的准确性和规范性; +3. **接口文档完善:** 及时更新负责功能的API接口文档,为前后端协作提供清晰参考; +4. **考核准备充分:** 认真准备UML技能考核,巩固系统设计理论知识; +5. **验收准备到位:** 做好α版本验收的准备工作,确保报表和公告功能模块运行稳定; +6. **技术难点攻克:** 重点关注报表数据统计的准确性和公告列表查询的性能优化; +7. **希望获得的帮助:** 希望在复杂数据统计实现和数据库查询优化方面获得更多指导。 + +--- + +## 【注】 + +1. **UML考核提醒:** UML技能考核需在本周五前完成,请提前做好复习准备; +2. **核心功能截止:** 报表功能和公告列表功能开发需在验收前完成并测试通过; +3. **文档修改截止:** 第8、9周文档校验修改工作需在周五前完成并提交; +4. **接口对接要求:** 及时与前端沟通接口规范,确保数据格式符合前端需求; +5. **性能优化建议:** 对公告列表查询考虑分页和缓存机制,提升用户体验。 \ No newline at end of file diff --git a/doc/process/weekly/week-11/members/wanglirong-weekly-summary-11.md b/doc/process/weekly/week-11/members/wanglirong-weekly-summary-11.md new file mode 100644 index 0000000..bd9e29c --- /dev/null +++ b/doc/process/weekly/week-11/members/wanglirong-weekly-summary-11.md @@ -0,0 +1,47 @@ +# 个人周总结-第11周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-12-01 +**结束时间:** 2025-12-07 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | UML技能考核准备与参加 | 完成 | 认真复习UML课程知识,参考团队项目UML图,按要求完成了UML技能考核 | +| 2 | 小组文档校验修改(第8、9周) | 完成 | 按照分工完成第8、9周小组文档的校验和修改工作,文档质量符合规范要求 | +| 3 | 后端报表功能开发 | 完成 | 独立完成报表功能后端实现,包括数据统计、分析结果生成等核心功能,已通过基本测试 | +| 4 | 普通用户获取公告列表功能 | 完成 | 独立实现普通用户获取公告列表功能,完成接口设计、业务逻辑实现和数据返回功能 | +| 5 | AI接口对接完善 | 完成 | 配合AI负责人员完成AI部分接口对接工作,确保自然语言转SQL功能稳定可用 | +| 6 | API接口文档更新 | 完成 | 更新了报表功能和公告列表功能的API接口文档,确保文档与实际实现一致 | +| 7 | α版本验收准备工作 | 部分完成 | 做好α版本验收准备,负责的报表和公告功能模块已就绪,系统整体验收因教师时间安排推迟至下周二 | +| 8 | 微调后的AI模型部署支持 | 完成 | 协助AI负责人员完成AI模型部署到云服务器,解决环境适配问题 | + +## 对团队工作的建议 + +1. **系统测试加强:** 建议在教师验收前组织全面的功能和压力测试,确保系统运行稳定性; +2. **文档维护持续:** 建议建立文档定期更新机制,确保技术文档与系统实现保持一致; +3. **知识共享机制:** 建议团队成员定期分享开发经验和问题解决方案,提升团队整体技术水平; +4. **进度跟踪优化:** 建议建立更细致的任务进度跟踪机制,确保各模块开发协调推进。 + +## 小结 + +1. **核心功能完成:** 成功独立完成报表功能和普通用户公告列表功能的后端开发工作,功能完整可靠; +2. **文档工作达标:** 按时完成UML考核、文档校验修改和API文档更新等多项文档任务; +3. **团队协作顺畅:** 与AI负责人员配合良好,顺利完成AI接口对接和模型部署支持工作; +4. **技术能力提升:** 通过复杂功能开发,提升了数据统计处理和接口设计能力; +5. **验收准备充分:** 负责的功能模块已准备就绪,为α版本验收打下坚实基础; +6. **时间安排调整:** 系统验收时间调整至下周二,为系统进一步完善提供了缓冲时间; +7. **质量意识增强:** 通过代码开发和文档工作,进一步强化了质量和规范意识。 + +--- + +## 【注】 + +1. **验收准备重点:** 需在下周二前对负责模块进行最后测试优化,确保验收顺利通过; +2. **技术文档完善:** 已按要求完成API文档更新,建议团队持续维护文档准确性; +3. **后续工作规划:** 验收后需根据反馈进一步完善功能,为β版本开发做准备; +4. **团队协作建议:** 建议加强跨模块沟通协调,提升整体开发效率。 \ No newline at end of file diff --git a/doc/process/weekly/week-11/members/yimuran-weekly-plan-11.md b/doc/process/weekly/week-11/members/yimuran-weekly-plan-11.md new file mode 100644 index 0000000..9bbf872 --- /dev/null +++ b/doc/process/weekly/week-11/members/yimuran-weekly-plan-11.md @@ -0,0 +1,27 @@ +# 个人周计划-第11周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-01 + +**结束时间:** 2025-12-07 + +## 本周任务计划安排 + + | **序号** | **计划内容** | **协作人** | **情况说明** | + | -------- | ---------------------------------- | -------------- | ------------------------------------------------------------ | + | 1 | **后端API接口冲刺** | 后端开发人员 | **【本周重点】** 完成剩余核心接口开发:1. 配合王利蓉完成AI接口的最终对接。2.配合前端对于已实现的接口进行前后端的联调 3 .对开发好的接口进行逻辑审查和微调。 | + | 2 | **小组文档校验与修改(第4、5周)** | 个人 | 根据小组分工,**负责检查与修正第4、5周的项目文档**(周计划、周总结、会议纪要)。重点检查格式规范、备注一致性及逻辑连贯性,确保于周五前提交归档。 | + | 3 | **API接口文档更新** | 前后端开发人员 | 更新并完善“认证与授权”、“用户设置”、“项目管理”等已稳定模块的API文档,确保文档与代码完全一致,方便前端进行最终联调。 | + | 4 | **UML技能考核与图表提交** | 全体成员 | 1. 复习UML理论知识,准备技能考核;2. 将最终UML图导出为规范格式,并合力编写相应说明文档,于周五晚10点前完成提交。 | + | 5 | **α版本验收准备** | 全体成员 | 配合团队进行Alpha版本验收演练,确保后端服务(部署在WSL或服务器)运行稳定,数据库数据正常,能够支撑现场演示。 | + +## 小结 + +1. **开发冲刺**:本周是Alpha验收前的最后冲刺,务必在周末前完成基本接口开发,消除前端的阻塞点。 +2. **文档整改**:除了完成自己的负责文档(第4、5周)检查外,同时确保UML考核顺利通过,不拖团队后腿。 +3. **验收保障**:重点保障演示环境的后端服务稳定性,确保在教师验收时系统不会出现宕机或数据错误。 \ No newline at end of file diff --git a/doc/process/weekly/week-11/members/yimuran-weekly-summary-11.md b/doc/process/weekly/week-11/members/yimuran-weekly-summary-11.md new file mode 100644 index 0000000..1d63c16 --- /dev/null +++ b/doc/process/weekly/week-11/members/yimuran-weekly-summary-11.md @@ -0,0 +1,31 @@ +1. # 个人周总结-第11周 + + ## 姓名与起止时间 + + **姓  名:** 伊木然 + + **团队名称:** 3班-葫芦娃救bug + + **开始时间:** 2025-12-01 + + **结束时间:** 2025-12-07 + + ## 本周任务完成情况 + + | **序号** | **计划内容** | **完成情况** | **情况说明** | + | -------- | ------------------------- | ------------------ | ------------------------------------------------------------ | + | 1 | **后端API接口冲刺** | 完成 | 配合前端完成了“报表统计”和“公告列表获取”功能的接口开发与联调。 | + | 2 | **小组文档校验与修改** | **完成** | 严格按计划完成了**第4、5周**项目文档(计划、总结、纪要)的全面回溯检查与修正,统一了格式规范,消除了早期的逻辑矛盾,并已归档提交。 | + | 3 | **API接口文档更新** | **完成** | API接口文档已初步更新,重点完善了“认证与授权”、“用户设置”及“项目管理”模块的参数说明,为前后端协作提供了准确依据。 | + | 4 | **UML技能考核与图表提交** | **完成** | 复习了UML建模理论,按规范完成了负责模块的UML图绘制,并配合团队在周五前成功提交了考核作业。 | + | 5 | **α版本验收准备** | **完成(验收延期)** | 已完成系统功能的内部集成测试,系统运行稳定,做好了充分的演示准备。**注:原定本周的教师验收因老师时间原因调整至下周二(12月9日)中午。** | + + ## 对团队工作的建议 + + 1. **攻克SQL执行对接**:目前后端整体进展顺利,但“SQL语句执行”部分仍处于紧锣密鼓的对接中。建议下周初集中后端力量配合AI组,优先攻克这一环节,确保验收时核心链路畅通。 + 2. **利用延期窗口自测**:利用验收延期的几天时间,建议团队进行一轮全流程的压力测试,特别是针对AI生成SQL并执行的耗时进行优化,避免演示时出现超时尴尬。 + + ## 小结 + + 1. **文档规范化**:通过对历史文档的系统性整改和UML考核的完成,团队的文档质量和规范性迈上了一个新台阶。 + 2. **蓄势待发**:虽然验收延期,但系统核心功能已基本冻结,目前状态良好,有信心通过下周二的正式验收。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/group/weekly-minutes-12.md b/doc/process/weekly/week-12/group/weekly-minutes-12.md new file mode 100644 index 0000000..03fe157 --- /dev/null +++ b/doc/process/weekly/week-12/group/weekly-minutes-12.md @@ -0,0 +1,101 @@ +# 小组会议纪要-第12周 + +## 会议记录概要 +**团队名称**:3班-葫芦娃救bug +**指导老师**:李友焕 +**主 持 人**:王利蓉(项目经理) +**记录人员**:梁峻耀 +**会议主题**:α版本优化、改进方向分工与下周任务安排 +**会议地点**:线上会议 +**会议时间**:2025-12-14 21:00-22:30 +**记录时间**:2025-12-14 22:45 +**参与人员**:全体成员 + +## 会议内容 + +### 1. 上周计划完成情况回顾 +根据第12周计划安排,各任务完成情况如下: +- **α版本验收**:已完成。全体成员参与,成功通过验收,但老师提出了多项改进建议。 +- **前端功能对接冲刺**:基本完成。项目管理、公告管理、会话管理等模块已完成对接,但报表与查询模块仍有部分Mock数据未完全消除。 +- **后端执行模块测试**:已完成。梁峻耀负责的SQL执行环节已合并到develop分支 +- **AI接口问题攻坚**:进行中。超时问题仍在优化中,需要与异步任务中的celery机制结合解决,梁峻耀正在研究。 +- **下一阶段架构预研**:已完成。伊木然协助李果霖已实现Schema生成与DDL生成分离的方案。 +- **AI模型下一轮微调**:进行中。李果霖已开始数据集生成和模型微调,但效果尚不确定。 +- **代码规范检查**:进行中。团队成员正在持续优化代码,清理冗余内容,但需进一步加强。 + +### 2. α版本改进方向与分工 +根据老师验收反馈,确定以下改进方向及负责人: + +| 序号 | 改进内容 | 负责人 | 状态/备注 | +| ---- | -------------------------- | --------------------- | ---------------------------- | +| 1 | 对话界面展示库表结构和界面 | 前端团队 | 优先级最高,需尽快完成 | +| 2 | ER图生成功能 | 李果霖 | 正在开发测试代码 | +| 3 | 前端历史记录功能 | 伊木然(后端)/前端团队 | 后端已完成,等待前端对接 | +| 4 | 准备5个典型验收场景 | 全体成员 | 暂停,先完成核心功能 | +| 5 | 模型选择功能 | 王利蓉(后端) | 后端接口已完成,需进一步测试 | +| 6 | 专业术语补充说明 | - | Beta版本实现,α版本暂停 | +| 7 | Schema和DDL进度条对应 | 前端团队 | 需配合后端实现 | +| 8 | 风险用户判断机制 | 伊木然 | 新增任务,需设计实现方案 | +| 9 | AI模型微调效果优化 | 李果霖 | 持续进行中,效果待验证 | + +**重点讨论**: +- **模型选择功能**:后端已实现接收model参数的接口,前端需在每次发送消息时自动携带当前选中的模型信息。默认选择团队自研AI模型。 +- **代码规范与安全**:全体成员必须对自己负责的代码进行自查,重点解决硬编码、安全鉴权、注释缺失等问题。虽有JWT认证机制,仍需确保各功能模块的权限校验完善。 + +### 3. 下周核心任务安排 +1. **文档工作**: + - 完成迭代开发计划第三稿 + - 确定需求规格说明书最终稿 + - 提交至头歌平台-团队项目分组作业 + +2. **α版本开发优化**: + - 根据改进方向完成α版本优化 + - 重点攻克对话界面库表结构展示与模型选择功能 + +3. **个人技能考试准备**: + - 全体成员分配时间准备个人技能考试 + - 重点关注与项目相关的技术点 + +4. **第二次小班讨论课准备**: + - 准备α版本评审材料 + - 根据老师要求准备展示内容与形式 + +## 后续任务安排 + +### (1)具体分工确认 +- **文档工作**:王利蓉(统筹)、伊木然(迭代计划)、李文韬(需求规格) +- **前端优化**:李文韬、李果霖(重点:库表结构展示、历史记录、进度条对应) +- **后端优化**:梁峻耀(celery异步任务和定时任务)、伊木然(风险用户判断) +- **AI模块**:李果霖(模型微调)、王利蓉(接口优化) +- **周计划制定**:李果霖 +- **会议纪要**:梁峻耀 + +### (2)会议安排 +- 下周例会时间:2025-12-21 星期日 晚上21:00 +- 特别提醒:提前准备个人技能考试相关资料,分配好项目与考试准备时间 + +## 问题总结 + +### 已解决问题 +- α版本成功通过验收 +- Schema生成与DDL生成分离方案已实现 +- 模型选择功能后端接口已完成 +- JWT认证机制已覆盖主要功能模块 + +### 待解决问题 +- MySQL用户检测问题需尽快解决 +- 对话界面库表结构展示功能尚未完成 +- AI模型超时问题仍需优化 +- 代码规范性检查需全员持续推进 +- 风险用户判断机制设计方案待确定 + +## 小组协作情况总结 +本周团队协作高效,面对验收反馈能够快速响应并确定改进方向。前后端团队沟通顺畅,问题反馈及时。特别是针对模型选择功能的讨论,团队成员能够充分交流技术细节,达成共识。伊木然主动承担新增的架构预研与风险用户判断任务,体现了团队责任感。 + +## 一周纪律情况总结 +全体成员准时参加周会,讨论积极热烈。验收阶段全员预留时间,展现了良好的团队纪律性。代码自查工作虽有压力,但成员均表示会按计划推进,确保质量。 + +## 备注 +1. **服务器使用**:测试AI功能时,如遇自研模型服务器未开启,可选用其他已配置模型进行测试。 +2. **代码安全**:严禁将API密钥等敏感信息硬编码在前端或公共代码中,必须通过环境变量或安全配置管理。 +3. **时间管理**:第14周将进入Beta版本开发,同时面临个人技能考试,请各位成员合理规划时间,平衡项目与学习任务。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/group/weekly-plan-12.md b/doc/process/weekly/week-12/group/weekly-plan-12.md new file mode 100644 index 0000000..604d60c --- /dev/null +++ b/doc/process/weekly/week-12/group/weekly-plan-12.md @@ -0,0 +1,32 @@ +# 小组周计划-第12周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-08 + +**结束时间:** 2025-12-14 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人** | **情况说明** | +| -------- | -------------------- | --------------- | ------------------------------------------------------------ | +| 1 | **α版本验收** | 全体成员 | **【本周最重要】** 完成α版本验收工作。初步定于**周二**进行,需提前准备好演示环境、测试数据及相关文档。 | +| 2 | **前端功能对接冲刺** | 前端开发人员 | 1. **报表与查询**:完成这两个核心板块的后端对接,消除Mock数据;2. **管理员模块**:完成剩余部分的接口对接;3. **对话模块**:优化对话界面的交互逻辑,确保消息收发顺畅。 | +| 3 | **后端执行模块测试** | 梁峻耀 | 专注于SQL执行环节的测试与修改,确保生成的SQL能正确在目标库执行并返回结果,修复执行过程中出现的Bug。 | +| 4 | **AI接口问题攻坚** | AI负责人员/后端 | 解决AI对接过程中的问题: **超时问题**:优化调用链路或增加超时重试机制,提升响应稳定性。 | +| 5 | **下一阶段架构预研** | 伊木然 | 针对Beta版本(第二阶段)的需求进行技术预研,重点规划**Schema生成与DDL生成分离**的实现方案,为下一版开发做准备。 | +| 6 | **AI模型下一轮微调** | AI负责人员 | 扩充非select语句的数据集,为Beta版本做准备。随时响应调用需求。 | +| 7 | **代码规范检查** | 全体开发人员 | 根据验收进度和空余时间,自行斟酌进行代码规范性检查与优化,清理冗余代码和注释。 | + +## 小结 + +1. **全力保验收**:本周的首要目标是顺利通过α版本验收(周二)。 +2. **补齐功能短板**:AI的格式/超时问题是目前系统的主要短板,必须在本周内集中攻克,否则将严重影响演示效果。 + +## 【注】 + +1. **验收时间**:请全员预留周二的时间,确保全员在场应对可能的提问或演示需求。 +2. **联调响应**:本周是Bug修复和联调的高峰期,前后端及AI组需保持实时在线沟通,发现问题立即解决。 +3. **功能取舍**:若代码检查(任务7)与核心功能修复冲突,优先保证功能修复。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/group/weekly-summary-12.md b/doc/process/weekly/week-12/group/weekly-summary-12.md new file mode 100644 index 0000000..bd56007 --- /dev/null +++ b/doc/process/weekly/week-12/group/weekly-summary-12.md @@ -0,0 +1,27 @@ +# 小组周总结-第12周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-08 + +**结束时间:** 2025-12-14 + +### 本周任务完成情况 + +| **序号** | **计划内容** | **是否完成** | **情况说明** | +| -------- | -------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **α版本验收** | **完成** | 团队全员配合授课教师完成了α版本验收工作。演示过程顺利,核心业务流程闭环,针对老师提出的反馈已记录并在后续迭代中优化。 | +| 2 | **前端功能对接冲刺** | **完成** | 前端成功完成了报表与查询模块的后端对接,彻底移除了模拟数据;管理员及对话模块的交互逻辑也已优化上线,用户体验流畅。 | +| 3 | **后端执行模块测试** | **完成** | 梁峻耀完成了SQL执行环节的深度测试与Bug修复,确保了生成的SQL语句能够在目标数据库中正确执行并返回结果。 | +| 4 | **AI接口问题攻坚** | **进行中** | 格式清洗问题已基本解决,但AI响应的超时问题在复杂场景下仍偶有发生,目前正在尝试优化调用链路和增加重试机制,需延续至下周继续攻坚。 | +| 5 | **下一阶段架构预研** | **完成** | 伊木然根据设定的schema和DDl分离方案,已经完成了对分离方案的实现,等下周与前端对接。 | +| 6 | **AI模型下一轮微调** | **进行中** | AI负责人员正在按计划扩充非Select语句(如Update, Delete)的训练数据集,微调工作正在进行中,旨在为Beta版本提供更全面的SQL支持。 | +| 7 | **代码规范检查** | **进行中** | 开发人员在验收间隙进行了部分核心模块的代码自查,清理了冗余代码,但全面的规范性检查将结合Beta版本的开发持续进行。 | + +### 小结 + +1. **验收目标达成**:本周最核心的里程碑是**顺利通过了α版本验收**。团队在时间紧迫的情况下,完成了所有核心功能的联调与修复,保证了演示的成功。 +2. **前后端完全打通**:随着报表和查询接口的对接完成,系统彻底告别了“Mock数据”时代,具备了真实的业务处理能力。 +3. **持续攻坚方向**:AI 接口的**稳定性(超时)**和**模型的覆盖度(非查询语句)**是目前的主要短板,也是下周Beta版本启动初期的重点攻坚对象。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/members/liangjunyao-weekly-plan-12.md b/doc/process/weekly/week-12/members/liangjunyao-weekly-plan-12.md new file mode 100644 index 0000000..50026c6 --- /dev/null +++ b/doc/process/weekly/week-12/members/liangjunyao-weekly-plan-12.md @@ -0,0 +1,28 @@ +# 个人周计划-第12周 + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-12-08 +**结束时间**:2025-12-14 + +--- + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | α版本验收工作 | 全体成员 | 【核心任务】全力配合教师验收:提前准备演示环境与测试数据;确保系统功能完整、运行稳定;重点展示已实现的Redis缓存优化;记录验收反馈,为后续优化提供依据。 | +| 2 | 后端执行模块测试 | 个人 | 【核心任务】专注于SQL执行环节的测试与修改:确保生成的SQL能正确在目标库执行并返回结果;修复执行过程中出现的Bug;针对验收中发现的问题进行紧急修复;编写SQL执行异常的处理机制。 | +| 3 | Redis缓存机制完善 | 前端开发人员 | 【技术优化】完成业务术语库的Redis缓存支持:解决数据结构与缓存更新策略问题;与前端完成系统公告缓存接口联调;评估缓存命中率与性能提升效果;记录缓存策略文档。 | +| 4 | Celery稳定性问题排查 | 个人 | 【技术攻关】解决Celery容器崩溃问题:分析崩溃日志,定位Redis连接或资源限制问题;实现至少一个简单的异步任务(如邮件通知)作为验证;为后续报表导出功能奠定基础。 | +| 5 | 验收问题修复与代码规范 | 全体开发人员 | 【质量保障】根据验收反馈进行问题修复;参与代码规范性检查与优化;清理冗余代码和注释;确保核心功能代码符合团队编码标准。 | +| 6 | 性能监控埋点与测试 | 个人 | 【技术准备】为关键接口添加性能监控埋点;验证Redis缓存对系统响应时间的影响;准备性能测试用例,为Beta版本性能优化提供数据支持。 | +| 7 | Beta版本技术预研准备 | 伊木然 | 【前瞻工作】了解下一阶段架构方向,特别是Schema生成与DDL分离方案;思考Redis/Celery如何融入下一阶段架构;为Beta版本开发做好技术储备。 | + +## 小结 + +**核心目标**: 确保α版本验收顺利通过,重点关注SQL执行模块的稳定性和Redis缓存优化成果; 解决Celery稳定性问题,为后续异步任务实现奠定基础; 为Beta版本开发做好技术准备,特别是性能监控与架构预研。 + +**风险控制**: 采用"先修复、后优化"策略,确保验收功能优先级最高; 修改前做好系统备份,保留可回滚版本; 对关键改动进行充分测试,避免引入新问题。 + +**协同重点**: 与前端紧密配合完成公告缓存联调; 与AI团队沟通SQL执行问题,确保AI生成SQL的准确性; 响应验收反馈,及时修复问题; 为下一阶段架构预研提供后端视角建议。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/members/liangjunyao-weekly-summary-12.md b/doc/process/weekly/week-12/members/liangjunyao-weekly-summary-12.md new file mode 100644 index 0000000..913239b --- /dev/null +++ b/doc/process/weekly/week-12/members/liangjunyao-weekly-summary-12.md @@ -0,0 +1,33 @@ +# 个人周总结 - 第12周 +姓  名:梁峻耀 +团队名称:3班-葫芦娃救bug +开始时间:2025-12-08 +结束时间:2025-12-14 + +## 本周任务完成情况 +| 序号 | 计划内容 | 是否完成 | 情况说明 | +| ---- | ------------------------ | -------- | ------------------------------------------------------------ | +| 1 | 后端执行模块测试 | 完成 | 成功完成SQL执行环节的深度测试与Bug修复工作,确保生成的SQL语句能够在目标数据库中正确执行并返回结果;针对α版本验收中发现的问题进行了针对性修复,保证了演示过程的顺利进行;与前端团队完成对接,消除了Mock数据依赖,实现了真实数据流转。 | +| 2 | Redis缓存机制引入 | 进行中 | 完成缓存策略设计与基础框架搭建;正在进行业务术语库的缓存支持开发,已解决上周遇到的数据结构问题;缓存失效机制正在测试中,预计下周初完成全部开发工作。 | +| 3 | Celery异步任务稳定性优化 | 完成 | 成功定位并解决Celery Worker频繁崩溃问题,确认是Docker镜像配置问题:修正了镜像中的依赖版本冲突,优化了Redis连接池配置,系统稳定性显著提高。 | +| 4 | 系统性能监控埋点测试 | 未完成 | 因α版本验收优先级较高,性能监控埋点测试被推迟;计划在下周Beta版本开发阶段继续推进。 | +| 5 | α版本验收支持 | 完成 | 全程参与周二α版本验收工作,负责后端执行模块演示与问题解答;针对验收过程中发现的问题进行了即时修复,保障了验收顺利通过。 | +| 6 | API接口文档更新 | 完成 | 根据验收反馈和代码变更,更新了执行模块相关API文档,确保与当前实现保持一致。 | +| 7 | 代码规范与注释完善 | 部分完成 | 对已完成的后端执行模块和Celery相关代码补充了详细注释;Redis缓存代码的注释因仍在开发中,将在下周完成后再统一完善。 | + +## 小结 +**核心进展**: +- 完成了SQL执行环节的关键测试与修复工作; +- 突破技术难点,成功解决Celery稳定性问题,确认是镜像配置问题而非代码缺陷,避免了不必要的代码重构; +- Redis缓存机制取得实质性进展,为系统性能优化打下基础。 + +**协作沟通**: + +- 与前端团队紧密合作,确保执行模块的接口对接顺利完成; +- 在α版本验收过程中,与团队成员协同应对评审问题,展示了良好的团队协作能力; +- 及时汇报Celery问题的排查进展,避免了问题扩大化。 + +**技术与风险**: + +- 采取优先级管理策略:根据团队"功能取舍"原则,将性能监控埋点测试延后,优先保障了核心功能的稳定性和验收通过; +- 风险预判:Redis缓存尚未覆盖全部核心场景,计划在Beta版本中继续完善,避免对系统性能造成瓶颈。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/members/liguolin-weekly-plan-12.md b/doc/process/weekly/week-12/members/liguolin-weekly-plan-12.md new file mode 100644 index 0000000..af13edb --- /dev/null +++ b/doc/process/weekly/week-12/members/liguolin-weekly-plan-12.md @@ -0,0 +1,29 @@ +# 个人周计划-第12周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-12-08 + +结束时间:2025-12-14 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | -------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **α版本验收配合** | 全体成员 | **【本周最重要】** 配合授课教师及助教完成周二的α版本验收工作。提前准备好演示环境、测试数据,确保演示流程中无阻断性Bug。 | +| 2 | **前端对话功能深入** | 全体成员 | 1. **报表与查询**:完成核心板块的后端对接,彻底消除Mock数据; 2. **管理员模块**:完成剩余接口对接; 3. **对话模块**:优化界面交互逻辑,确保消息收发顺畅。 | +| 3 | **AI模型的超时问题** | AI开发人员 | 优化系统prompt或增加超时重试机制,提升响应稳定性; | +| 4 | **AI模型功能扩充** | AI开发人员 | 限于Text2SQL训练集绝大多数都是SELECT,模型对于非SELECT语句的请求响应较差,下一步将扩充非Select语句的数据集,为Beta版本的实现做准备。 | +| 5 | **前端UI的美化** | 前端开发人员 | 在开发前期忙于搭建页面和对接接口测试,后续功能稳定后将美化各个页面UI组件与布局 | +| 6 | **代码规范性检查** | 全体开发 | 在验收结束后,利用空余时间自行进行代码规范性检查与优化,清理开发过程中的冗余代码和注释。 | +| 7 | **整理个人笔记与撰写周报** | 个人 | 整理本周在AI微调参数调整、前后端中的技术笔记,撰写个人周总结。 | + +## 小结 + +1. **训练数据集**:本周个人优先完成非SELECT数据集的扩充,争取下一轮微调后能够提高模型对于CRUD语言的正确响应 +2. **顺利验收**:小组的首要目标是和项目老师完成周二的α版本验收,确保演示环境的稳定性和数据的真实性。 +3. **补齐功能短板**:解决前端报表/查询对接以及AI接口的超时问题,这两个是目前系统的主要短板,需要在本周内解决,否则会影响演示效果。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/members/liguolin-weekly-summary-12.md b/doc/process/weekly/week-12/members/liguolin-weekly-summary-12.md new file mode 100644 index 0000000..d57c3db --- /dev/null +++ b/doc/process/weekly/week-12/members/liguolin-weekly-summary-12.md @@ -0,0 +1,29 @@ +# 个人周总结-第12周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-12-08 + +结束时间:2025-12-14 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **完成情况** | **情况说明** | +| -------- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **α版本验收配合** | **已完成** | **【里程碑】** 全程参与并通过了周二的α版本验收,负责部分的演示流程顺畅。针对老师提出的一系列问题,已记录并列入改进计划。 | +| 2 | **下一阶段架构预研** | **已完成** | **【架构优化】** 针对Beta版本需求,成功实现了**Schema生成与DDL生成分离**的技术方案,解决了此前请求耦合的问题,为下一版开发打下基础。 | +| 3 | **AI模型功能扩充** | **进行中** | 利用qwen-trubo模型api生成批量数据集,经过不断的调整prompt完成了**非SELECT语句(DML)**数据集的构建与生成,并开启了第二轮模型微调,旨在解决模型对增删改操作响应较差的问题,目前效果待验证。 | +| 4 | **后端模型选择功能** | **已完成** | 完成了后端接收 `model` 参数接口的开发与测试,支持前端传递模型选择信息,默认对接团队自研AI模型,等待前端联调。 | +| 5 | **前端对话功能深入** | **基本完成** | 管理员模块接口对接完毕;报表与查询模块完成了大部分对接,但仍存少量Mock数据;已着手规划对话界面展示库表结构的UI部署。 | +| 6 | **代码规范性检查** | **持续进行** | 在验收后对负责模块进行了初步的代码规范自查,重点优化了部分硬编码问题,并配合团队成员统一注释风格。 | +| 7 | **整理个人笔记与周报** | **完成** | 总结AI微调过程中遇到的问题和解决办法,梳理前后端代码架构以便后续接入er图和随时帮助同伴解决大模型问题 | + +## 小结 + +1. **验收顺利通过**:本周的首要目标达成,协助团队顺利通过了α版本验收,虽然存在细节问题(如超时、用户检测),但核心业务流程已获认可并整理老师提出的问题着手进行整改。 +2. **架构与接口先行**:在完成验收的同时,超前完成了**Schema与DDL分离**的架构设计与实现,并完成了模型选择的后端接口,为下周的Beta版本开发做好了技术储备。 +3. **AI能力拓展**:工作重心从单纯的“跑通”转向“调优”,重点扩充了DML数据集进行微调,致力于解决模型只能处理查询语句的短板。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/members/liwentao-weekly-plan-12.md b/doc/process/weekly/week-12/members/liwentao-weekly-plan-12.md new file mode 100644 index 0000000..e9d234c --- /dev/null +++ b/doc/process/weekly/week-12/members/liwentao-weekly-plan-12.md @@ -0,0 +1,36 @@ +# 个人周计划-第12周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-12-08 + +**结束时间**:2025-12-14 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | ----------------------- | --------------- | ------------------------------------------------------------ | +| 1 | α版本验收 | 全体成员 | **[本周重要]** 配合团队完成α版本验收工作。初步定于周二进行,需提前准备好前端演示环境及相关测试数据,确保演示过程无交互Bug。 | +| 2 | 核心功能对接-报表与查询 | 后端开发人员 | 完成报表与查询两个核心板块的后端接口对接,移除前端Mock数据,确保数据交互真实有效。 | +| 3 | 管理员模块收尾 | 后端开发人员 | 完成管理员模块剩余部分的接口对接工作,确保管理后台功能完整可用。 | +| 4 | 对话模块交互优化 | AI负责人员/后端 | 优化对话界面的交互逻辑,重点解决消息收发的流畅度问题,配合后端解决超时等异常情况下的前端反馈。 | +| 5 | 界面布局改造 | 个人 | **[新增任务]** 对现有前端界面进行布局适配,调整布局参数,确保系统在不同尺寸的显示设备上均能正常显示和使用。 | +| 6 | 代码规范检查 | 全体开发人员 | 根据验收进度和空余时间,对负责的前端代码进行规范性自查与优化,清理冗余代码和注释。 | +| 7 | 一周总结 | 个人 | 汇总α版本准备结果,整理前端各模块对接完成情况,参与制定小组下周计划。 | + +## 小结 + +**全力保验收:** 本周的首要目标是顺利通过周二的α版本验收。作为前端,需重点保证演示环境的稳定性,确保核心流程(报表、查询、对话)无“翻车”风险。 + +**功能联调冲刺:** 本周是去除Mock数据、实现真实交互的关键期。需将任务细化为报表、管理员、对话三个子方向逐一攻破,同时新增**界面适配**任务,提升系统的用户体验。 + +**质量与规范:** 在高强度的联调过程中,仍需兼顾代码质量,利用空隙时间清理冗余代码,保持项目代码的整洁与规范。 + +## 【注】 + +1. **时间预留:** 预留**周二**时间,确保全员在场应对验收时的提问或临时演示需求。 +2. **联调响应:** 本周是Bug修复高峰期,需保持与后端的**实时在线沟通**,发现接口问题立即反馈解决,避免阻塞进度。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/members/liwentao-weekly-summary-12.md b/doc/process/weekly/week-12/members/liwentao-weekly-summary-12.md new file mode 100644 index 0000000..c7a3d6f --- /dev/null +++ b/doc/process/weekly/week-12/members/liwentao-weekly-summary-12.md @@ -0,0 +1,34 @@ +# 个人周总结-第12周 + +## 姓名与起止时间 + +姓 名:李文韬 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-12-08 + +结束时间:2025-12-14 + +## 本周任务完成情况 + +| **序号** | **任务内容** | **是否完成** | **情况说明** | +| -------- | ----------------------- | ------------ | ------------------------------------------------------------ | +| 1 | α版本验收 | 已完成 | 配合团队顺利完成了α版本验收工作。提前准备了演示环境,核心业务流程演示无交互Bug,成功通过验收,并记录了老师提出的改进建议。 | +| 2 | 核心功能对接-报表与查询 | 已完成 | 完成了报表与查询核心板块的后端接口对接,移除了前端Mock数据,实现了真实的数据交互功能。 | +| 3 | 管理员模块收尾 | 已完成 | 完成了管理员模块剩余接口的对接工作,确保了用户设置、公告管理等后台功能的完整可用性。 | +| 4 | 对话模块交互优化 | 已完成 | 优化了对话界面的交互逻辑,重点改善了消息收发的流畅度,并配合后端初步处理了异常情况下的前端反馈机制。 | +| 5 | 界面布局改造 | 已完成 | 针对演示需求,对现有前端界面进行了布局适配与参数调整,确保了系统在不同尺寸设备上的显示效果。 | +| 6 | 代码规范检查 | 进行中 | 在验收间隙对负责的前端代码进行了初步规范性自查与冗余清理,全面的规范检查将结合Beta版本开发持续推进。 | +| 7 | 一周总结 | 已经完成 | 汇总了α版本验收情况,梳理了前端各模块对接成果,并完成了本周总结。 | + +## 小结 + +1. **验收目标达成:** 本周重点保障了α版本验收的顺利通过,演示环境稳定,核心流程闭环。 +2. **交互体验提升:** 通过新增的界面适配任务和对话逻辑优化,提升了系统的整体视觉效果和操作流畅度。 +3. **明确改进方向:** 根据验收反馈,已明确后续需重点攻克对话界面库表结构展示及历史记录功能。 + +## 【注】 + +1. **下周重点:** 启动Beta版本开发,重点落实验收反馈中的“对话界面库表结构展示”功能,并完成需求规格说明书的定稿。 +2. **考试准备:** 随着个人技能考试临近,下周需合理分配时间,在推进项目优化的同时兼顾考试复习。 diff --git a/doc/process/weekly/week-12/members/wanglirong-weekly-plan-12.md b/doc/process/weekly/week-12/members/wanglirong-weekly-plan-12.md new file mode 100644 index 0000000..0cd35cc --- /dev/null +++ b/doc/process/weekly/week-12/members/wanglirong-weekly-plan-12.md @@ -0,0 +1,38 @@ +# 个人周计划-第12周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-12-08 +**结束时间:** 2025-12-14 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | **α版本验收准备与参与** | 全体成员 | **【本周最重要】** 全力准备周二α版本验收,确保负责的报表和公告功能演示正常,提前准备好测试数据 | +| 2 | **报表功能前端对接支持** | 前端开发人员 | 积极配合前端完成报表功能的后端对接,提供技术支持和接口调试,确保数据正确传输 | +| 3 | **AI接口问题攻坚支持** | 李果霖,李文韬 | 协助解决AI接口的超时问题,优化调用链路,提升响应稳定性,确保验收演示顺利 | +| 4 | **SQL执行模块测试协助** | 梁峻耀 | 配合进行SQL执行环节的测试,确保生成的SQL能正确执行并返回结果 | +| 5 | **代码规范性检查与优化** | 个人 | 根据验收进度安排,对负责模块代码进行规范性检查和优化,清理冗余代码和注释 | +| 6 | **验收后功能改进规划** | 个人 | 根据验收反馈,制定负责模块的改进计划,为后续开发做好准备 | +| 7 | **Beta版本技术学习** | 个人 | 了解Beta版本的技术需求,提前学习相关知识,为下一阶段开发做准备 | + +## 小结 + +1. **验收优先:** 本周核心任务是确保α版本验收顺利通过,全力配合团队完成演示准备工作; +2. **技术支持:** 积极为前端提供报表功能对接支持,确保数据交互正常; +3. **问题解决:** 协助解决AI接口超时等关键技术问题,提升系统稳定性; +4. **质量保障:** 在验收间隙进行代码规范性检查,确保代码质量; +5. **后续规划:** 根据验收反馈制定改进计划,为Beta版本开发奠定基础; +6. **希望获得的帮助:** 希望在AI接口优化和性能调优方面获得更多技术指导。 + +--- + +## 【注】 + +1. **验收时间安排:** 请预留周二全天时间,确保全程参与验收工作; +2. **技术支持准备:** 提前熟悉负责模块的所有接口和功能,做好技术答疑准备; +3. **沟通协调要求:** 保持与前端和AI组的密切沟通,发现问题及时协调解决; +4. **时间分配建议:** 验收前集中精力准备演示,验收后及时进行总结和改进规划。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/members/wanglirong-weekly-summary-12.md b/doc/process/weekly/week-12/members/wanglirong-weekly-summary-12.md new file mode 100644 index 0000000..de615c6 --- /dev/null +++ b/doc/process/weekly/week-12/members/wanglirong-weekly-summary-12.md @@ -0,0 +1,46 @@ +# 个人周总结-第12周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-12-08 +**结束时间:** 2025-12-14 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | α版本验收准备与参与 | 完成 | 全力配合团队完成α版本验收,负责的报表和公告功能演示正常,测试数据准备充分 | +| 2 | 报表功能前端对接支持 | 完成 | 积极配合前端完成报表功能的后端对接,提供接口调试支持,确保数据正确传输 | +| 3 | AI接口问题攻坚支持 | 部分完成 | 协助AI组解决格式清洗问题,但超时问题在复杂场景下仍偶有发生,需要下周继续攻坚 | +| 4 | SQL执行模块测试协助 | 完成 | 配合梁峻耀完成SQL执行环节的测试,确保生成的SQL语句能正确执行并返回结果 | +| 5 | 代码规范性检查与优化 | 进行中 | 在验收间隙进行了部分核心模块的代码自查,清理了冗余代码,规范性检查持续进行中 | +| 6 | 验收后功能改进规划 | 完成 | 根据验收反馈制定了负责模块的改进计划,明确了后续开发重点 | +| 7 | Beta版本技术学习 | 完成 | 了解了Beta版本的技术需求,初步学习了相关技术知识 | + +## 对团队工作的建议 + +1. **问题跟踪机制:** 建议建立系统化的问题跟踪和解决机制,特别是针对AI接口超时等复杂技术问题; +2. **性能优化专项:** 建议组织专门的技术攻关小组,针对系统性能瓶颈进行集中优化; +3. **文档同步更新:** 建议在功能改进和问题修复后,及时更新相关技术文档; +4. **知识沉淀分享:** 建议将验收过程中的问题和解决方案进行整理分享,形成团队知识库。 + +## 小结 + +1. **验收顺利通过:** 核心目标达成,团队顺利通过α版本验收,演示过程流畅,核心业务闭环完整; +2. **功能对接成功:** 成功完成报表功能的前后端对接,彻底移除了模拟数据,系统具备真实业务处理能力; +3. **技术协作高效:** 在AI接口问题攻坚和SQL执行测试中与团队成员配合良好,展现了良好的团队协作精神; +4. **问题识别明确:** 明确了AI接口稳定性是当前主要短板,为后续优化提供了清晰方向; +5. **规划准备充分:** 根据验收反馈制定了详细的改进计划,为Beta版本开发做好了准备; +6. **技术学习进步:** 通过学习Beta版本相关技术,拓展了技术视野,提升了综合能力; +7. **质量意识提升:** 通过代码规范性检查,进一步强化了代码质量和规范意识。 + +--- + +## 【注】 + +1. **技术攻坚重点:** 下周需重点协助解决AI接口超时问题,提升系统稳定性; +2. **改进计划执行:** 将按照制定的改进计划,稳步推进负责模块的优化工作; +3. **知识经验总结:** 建议团队整理验收过程中的经验教训,为后续开发提供参考; +4. **持续学习提升:** 需继续深入学习Beta版本相关技术,为下一阶段开发做好准备。 \ No newline at end of file diff --git a/doc/process/weekly/week-12/members/yimuran-weekly-plan-12.md b/doc/process/weekly/week-12/members/yimuran-weekly-plan-12.md new file mode 100644 index 0000000..85cf211 --- /dev/null +++ b/doc/process/weekly/week-12/members/yimuran-weekly-plan-12.md @@ -0,0 +1,34 @@ +# 个人周计划-第12周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-08 + +**结束时间:** 2025-12-14 + +## 本周核心目标 + +1. **保障Alpha验收**:全力配合团队完成周二的辅导教师验收,确保负责的后端模块演示无误。 +2. **架构技术预研**:针对Beta版本需求,深入研究“Schema生成与DDL语句生成分离”的技术方案。 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人/协作** | **情况说明** | +| -------- | ------------------- | -------------------- | ------------------------------------------------------------ | +| 1 | **α版本验收与演示** | 个人 (全体成员) | **【本周二】** 参与项目验收会议。负责演示后端服务运行状态,解答老师关于用户管理及项目管理模块的技术提问,确保演示环境稳定。 | +| 2 | **验收期紧急支持** | 个人 (与AI/前端协作) | **【周一至周二】** 在验收前夕,随时响应前端(李果霖)和AI组的需求,协助排查接口超时或数据格式异常的问题,优先修复影响演示的Bug。 | +| 3 | **Beta版架构预研** | 个人 | **【本周重点】** 针对当前痛点,设计第二阶段的优化方案:将原有的“一步生成”拆解为 **“先生成Schema中间态,再生成DDL语句”** 的两步走策略,以提高复杂场景下的准确率。 | +| 4 | **代码规范性自查** | 个人 | 在验收结束后的空余时间,对Alpha版本的后端代码进行一轮清理,删除调试用的Print语句和冗余注释,规范变量命名。 | +| 5 | **周总结制定** | 个人 | **【周日完成】** 整理本周Alpha验收情况与代码预研成果,撰写个人及小组第12周周总结。 | + +## 小结 + +1. **站好最后一班岗**:本周初的核心任务是确Alpha版本“稳”字当头,一切为了周二的验收演示服务。 + +2. **承上启下**:验收结束后,我的工作重心将立即转移到Beta版本的技术规划上。**“Schema与DDL分离”** 是提升系统智能度的关键架构调整,本周需产出初步的技术思路。 + + \ No newline at end of file diff --git a/doc/process/weekly/week-12/members/yimuran-weekly-summary-12.md b/doc/process/weekly/week-12/members/yimuran-weekly-summary-12.md new file mode 100644 index 0000000..c3fb423 --- /dev/null +++ b/doc/process/weekly/week-12/members/yimuran-weekly-summary-12.md @@ -0,0 +1,29 @@ +# 个人周总结-第12周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + + **团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-08 + +**结束时间:** 2025-12-14 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **完成情况** | **情况说明** | +| -------- | ------------------------ | ------------ | ------------------------------------------------------------ | +| 1 | **α版本验收与演示** | **完成** | **【核心成果】** 全程参与了周二的项目验收,演示过程顺利,核心业务流程闭环,针对老师提出的反馈已记录并在后续迭代中优化。 | +| 2 | **验收期紧急支持** | **完成** | 在验收前夕与前端(李果霖)和AI组紧密配合,紧急修复了演示环境中出现的接口偶发性超时问题,确保了验收时的系统稳定性。 | +| 3 | **Beta版架构预研与实现** | **完成** | **【本周重点】** 针对Text-to-SQL准确率瓶颈,完成了Beta版“Schema与DDL分离”的技术预研,还完成了该分离方案的代码实现,目前已具备对接条件,计划下周与前端进行联调。 | +| 4 | **代码规范性自查** | **完成** | 在验收结束后,对Alpha版本的后端代码进行了清理,删除了调试用的日志输出和冗余代码,提升了代码库的整洁度。 | +| 5 | **周总结与计划制定** | **完成** | 收集了Alpha验收的反馈意见(如AI响应速度、功能覆盖度等),撰写了个人及小组的本周总结。 | + + + +## 小结 + +1. **Alpha 完美收官**:本周最主要的工作是保障验收。作为后端核心开发,我确保了负责模块在演示中的零故障运行,见证了团队里程碑的达成。 +2. **架构实现突破**:超额完成了Beta版本的架构任务,从单纯的“预研”推进到了**“方案实现”**。成功将Schema生成与DDL生成解耦,为下一阶段提升复杂场景下的SQL生成准确率打下了坚实基础。 +3. **后续规划与反馈**:在推进开发的同时,完成了验收反馈的收集和下一阶段的规划工作,确保项目从Alpha平滑过渡到Beta阶段。 \ No newline at end of file diff --git a/doc/process/weekly/week-13/group/meeting-minutes-13.md b/doc/process/weekly/week-13/group/meeting-minutes-13.md new file mode 100644 index 0000000..f5edb69 --- /dev/null +++ b/doc/process/weekly/week-13/group/meeting-minutes-13.md @@ -0,0 +1,87 @@ +# 小组会议纪要-第13周 + +## 会议记录概要 + +**团队名称**:3班-葫芦娃救bug + +**指导老师**:李友焕 + +**主 持 人**:王利蓉(项目经理) + +**记录人员**:王利蓉 + +**会议主题**:核心功能稳定化、后端异步任务优化与下周任务部署 + +**会议地点**:二区一栋A01 + +**会议时间**:2025-12-21 19:48-20:48 + +**记录时间**:2025-12-21 21:00 + +**参与人员**:王利蓉、李果霖、伊木然、梁峻耀、李文韬 + +--- + +## 会议内容 + +### 1. 项目进度复盘与战略调整 + +会议明确了当前项目处于“beta版本优化”的关键期,重点由新功能开发转向**核心功能稳定化**。 + +* **功能舍弃**:鉴于期末考试压力及时间限制,决定暂时**搁置 RAG(检索增强生成)功能**的深度开发,优先保障现有功能的 Bug 消除。 +* **演示策略**:在后续测试中寻找存在的bug并优化,增加有代表性案例准备,重点突出已完成的稳定性功能。 + +### 2. 技术细节讨论与优化 + +* **异常处理**:后端将统一异常处理逻辑,区分业务类与系统类异常,返回 200 状态码但在 `message` 中包含中文错误信息,方便前端直接展示。 +* **异步任务与消息队列**:利用消息队列优化模型请求调度,解决多用户并发请求时的排队与冲突问题,这被视为项目的核心加分项。 +* **数据一致性**:前端与后端将实行“双重校验”,特别是针对业务术语重复等问题,数据库层将做强制约束。 + +### 3. 具体分工与任务安排 + +| 模块 | 具体任务描述 | 负责人 | 优先级 | +| --- | --- | --- | --- | +| **后端开发** | **多数据库适配**(SQLite和PostgreSQL等)实现与调试 | 梁峻耀,李果霖 | 高 | +| **后端开发** | 全局异常处理逻辑优化(异常包重构,中文信息返回) | 梁峻耀 | 中 | +| **后端开发** | 历史对话与业务术语上下文拼接逻辑(初期采用全量拼接) | 王利蓉,李果霖 | 中 | +| **前端开发** | **界面布局适配**(主聊天页面、Logo、按钮展示区域等) | 李文韬 | 高 | +| **前端开发** | 自定义弹窗开发(替换所有浏览器默认弹窗)及分页功能 | 李文韬 | 中 | +| **后端开发** | 用户账号加密、封禁判断逻辑及模型配置表(Key/Value管理) | 伊木然 | 中 | +| **Celery 异步任务与调度** | **核心加分项** Celery 环境部署,填充任务调度逻辑。优化消息队列,确保多用户并发请求时模型能有序排队。 |梁峻耀、李果霖 |高 | + +### 4. 下周计划与任务负责人确认 + +* **会议纪要整理(下周)**:**伊木然** +* **周计划文档撰写(下周)**:**李文韬** + +--- + +## 问题总结 + +### 已解决问题: + +1. 明确了异常处理的返回规范,解决了前后端错误提示不一致的问题。 +2. 确定了模型排队机制,通过消息队列缓解并发压力。 +3. 达成了“先稳核心、后搞优化”的共识,缓解了团队开发压力。 + +### 待解决问题: + +1. **多数据库适配**的技术方案仍需在下周前两天内攻克。 +2. 管理员配置模型功能需要新增数据库表结构支持。 +3. 学习通上的**代码规范考核**需全员在休息时间抽空查看并提分。 +4. 本周三有考试,各成员需自行协调开发与复习时间。 + +--- + +## 小组协作情况总结 + +1. **协作情况**:团队成员针对“双重校验”和“任务调度”进行了深度探讨,前后端沟通效率高。 +2. **纪律情况**:全体成员准时参加线下会议,并在关键任务点上达成了一致。 + +--- + +## 备注 + +1. **代码审计**:各成员在提交代码前需严格遵守代码规范,这直接关系到考核分数。 +2. **应急预案**:如数据库适配出现重大延时,将优先保证主数据库(MySQL)的演示流畅度。 + diff --git a/doc/process/weekly/week-13/group/weekly-plan-13.md b/doc/process/weekly/week-13/group/weekly-plan-13.md new file mode 100644 index 0000000..4bfab7b --- /dev/null +++ b/doc/process/weekly/week-13/group/weekly-plan-13.md @@ -0,0 +1,35 @@ +# 小组周计划-第13周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-15 + +**结束时间:** 2025-12-21 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人** | **情况说明** | +| -------- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **前端UI走查** | 前端开发人员 | 小班讨论进行UI走查,需要提前对前端布局进行美化和修改,并根据小班课意见完成修改 | +| 2 | **代码规范自查** | 全体成员 | 根据前后端代码架构和自身代码编写情况,规范化注释和代码编写,要求按照google编码规范进行,以待其它组进行检查 | +| 3 | **文档迭代与提交** | 全体成员 | **【本周重点】** 根据老师验收后的改进方案,更新以下文档并提交至头歌平台: 1. **《迭代开发计划》第三稿**; 2. **《需求规格说明书》最终稿**。 | +| 4 | **前端优化** | 前端开发人员 | **【改进重点】** 1. **对话界面结构化**:在对话中展示库表结构(**重要**); 2. 创建数据库的进度条显示:实现Schema生成与DDL生成的进度条对应显示; | +| 5 | **后端功能增强与ER图** | 李果霖 | 1. **ER图生成**:实现每个库表结构的图形化展示; 2. **更新后端接口**:迭代后端接口,确保更新之后的后端接口能够正常使用。 | +| 6 | **AI模型与风险控制** | AI负责人员 | 根据生成的DML数据集微调后,评估其微调效果;针对不确定性进行测试与调整。 | +| 7 | **风险用户判断** | 伊木然 | 研究并落实如何识别判断风险用户,并将其整合到后端代码中 | +| 8 | **个人技能考试准备** | 全体成员 | 在保证项目α版本评审顺利进行的前提下,合理分配时间进行个人技能考试的复习与准备。 | + +## 小结 + +1. **UI走查**:本周工作的重中之重是应对**小班讨论课的UI走查**(任务1)以及**两份核心文档的提交**(任务3)。 +2. **代码规范**:坚持**代码规范自查**(任务2),全员必须严格对照 **Google编码规范** 进行优化,以应对即将到来的跨组代码检查。 +3. **可视化落地**:前端的“对话界面结构化”与后端的“ER图生成”(任务4、5)是提升用户体验的核心改进点,需确保在UI走查前完成对接。 + +## 【注】 + +1. **代码规范红线**:请务必重视任务2,注释风格和命名规范需统一遵循Google标准,避免在跨组检查中因格式问题丢分。 +2. **前后端联调**:前端的进度条显示(任务4)与后端的接口更新(任务5)紧密相关,开发人员需保持实时沟通,确保数据流转正确。 +3. **风险控制集成**:风险用户判断(任务7)涉及管理员逻辑,集成时需注意不要影响现有主流程的稳定性。 +4. **时间平衡**:临近期末,在推进项目(特别是文档和UI优化)的同时,利用碎片时间高效准备个人技能考试(任务8)。 \ No newline at end of file diff --git a/doc/process/weekly/week-13/group/weekly-summary-13.md b/doc/process/weekly/week-13/group/weekly-summary-13.md new file mode 100644 index 0000000..32ceba8 --- /dev/null +++ b/doc/process/weekly/week-13/group/weekly-summary-13.md @@ -0,0 +1,30 @@ +# 小组周总结-第13周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-15 + +**结束时间:** 2025-12-21 + +### 本周任务完成情况 + +| **序号** | **计划内容** | **是否完成** | **情况说明** | +| -------- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **前端UI走查** | **已完成** | 前端团队顺利完成了UI界面的美化工作,并根据小班讨论课上的反馈意见进行了针对性的布局修改,通过了内部走查。 | +| 2 | **代码规范自查** | **进行中** | 团队内部已按Google规范进行了初步自查与注释优化。但由于**老师还是未公布跨组代码评审的名单**,最终的针对性检查仍在持续进行中,等待名单公布后做最后确认。 | +| 3 | **文档迭代与提交** | **已完成** | **【本周里程碑】** 按时完成了《迭代开发计划》第三稿及《需求规格说明书》最终稿的修订工作,并已成功提交至头歌平台。 | +| 4 | **前端优化** | **已完成** | 成功实现了**对话界面库表结构的可视化展示**,以及Schema生成与DDL生成过程的**进度条同步显示**,对用户友好展示,显著提升了交互体验。 | +| 5 | **后端功能增强与ER图** | **已完成** | 李果霖完成了ER图生成功能,实现了库表结构的图形化输出;同时完成了后端接口的迭代更新,确保了新旧接口的平滑过渡与正常使用。 | +| 6 | **AI模型与风险控制** | **进行中** | 完成了基于DML(增删改)数据集的模型微调工作,并将模型量化后部署在华为云服务器上,受成本以及精力影响其微调效果还未评估测试。后续需要评估微调效果进行前后对比 | +| 7 | **风险用户判断** | **未完成** | 这周计网和软工导两个小班压力比较大,且这部分内容属于β版本,因此尚未完成风险用户的判断与实现,计划延期至下周继续攻坚。 | +| 8 | **个人技能考试准备** | **进行中** | 团队成员在保证α版本开发与文档提交进度的前提下,合理规划时间,进行个人技能考试的复习准备,确保每个人都通过技能考核,认定为开发人员 | + +### 小结 + +1. α**功能完成**:截至本周五,**α版本已经开发完成**,核心功能均已实现,小班课上顺利展示α版本完成情况 +2. **交互体验大幅提升**:本周前端与后端的配合高效,成功落地了**ER图生成**、**对话结构化展示**及**进度条反馈**等可视化功能,解决了此前用户体验不够直观,无法随时提问项目的痛点。 +3. **文档里程碑达成**:核心文档(需求规格说明书、迭代计划)均已定稿并提交,标志着项目文档阶段性任务的圆满结束。 +4. **待解决项明确**:受客观因素影响(评审名单未出),代码规范检查依然保持待命状态;**风险用户判断**功能的逻辑实现受时间影响,放回β版本实现。 + diff --git a/doc/process/weekly/week-13/members/liangjunyao-weekly-plan-13.md b/doc/process/weekly/week-13/members/liangjunyao-weekly-plan-13.md new file mode 100644 index 0000000..551c5e5 --- /dev/null +++ b/doc/process/weekly/week-13/members/liangjunyao-weekly-plan-13.md @@ -0,0 +1,31 @@ +# 个人周计划-第13周 +姓  名:梁峻耀 +团队名称:3班-葫芦娃救bug +开始时间:2025-12-15 +结束时间:2025-12-21 + +## 本周任务计划安排 +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ----------------------- | ------------ | ------------------------------------------------------------ | +| 1 | Redis缓存机制完善与联调 | 前端开发人员 | 【核心任务】完成业务术语库的Redis缓存支持,解决上周遗留的数据结构问题;与前端完成缓存接口联调,确保数据一致性;测试缓存命中率,评估性能提升效果。 | +| 2 | 性能监控埋点测试 | 个人 | 【技术优化】完成系统关键路径的性能监控埋点开发;设计监控指标并验证数据采集准确性;与前端进度条显示功能对接,确保性能数据可视化;分析并优化热点接口。 | +| 3 | 代码规范自查 | 个人 | 【质量保障】严格按照Google编码规范,对后端执行模块、Redis缓存和Celery相关代码进行全面检查;补充缺失注释,优化代码结构,为后续跨组代码审查做准备。 | +| 4 | 文档迭代与提交 | 全体成员 | 【文档工作】更新《迭代开发计划》第三稿中与后端执行模块相关的内容;完善《需求规格说明书》中执行模块的接口说明;确保文档与代码实现保持一致。 | +| 5 | ER图与后端接口开发 | 李果霖 | 【新功能】配合完成ER图生成相关后端接口开发;实现Schema生成与DDL生成分离方案的后端逻辑;为前端进度条显示功能提供数据支持接口。 | +| 6 | 个人技能考试准备 | 个人 | 【个人发展】利用晚间和碎片时间高效准备个人技能考试;重点复习后端开发、数据库优化和系统架构相关知识点,确保项目与学习平衡。 | + +## 小结 +**核心目标**: +- 完成Redis缓存机制的最终落地与联调,为系统性能提升提供实质支撑; +- 补齐上周未完成的性能监控埋点测试,实现关键接口的性能可视化; +- 支持Beta版本新功能开发,特别是ER图生成与DDL分离方案的后端实现。 + +**风险控制**: +- 采用"渐进式"缓存策略:先在非核心模块验证,再逐步应用到核心业务; +- 性能埋点采用条件开关机制,避免对生产环境造成额外负担; +- 合理分配时间,确保考试准备不影响核心开发任务,优先保障系统功能交付。 + +**协同重点**: +- 与李果霖紧密协作,确保ER图生成功能的后端接口设计符合需求; +- 与前端团队保持每日同步,及时解决缓存与性能监控的对接问题; +- 与伊木然密切配合,完成风险用户判断功能的集成,做好测试验证; \ No newline at end of file diff --git a/doc/process/weekly/week-13/members/liangjunyao-weekly-summary-13.md b/doc/process/weekly/week-13/members/liangjunyao-weekly-summary-13.md new file mode 100644 index 0000000..7194c44 --- /dev/null +++ b/doc/process/weekly/week-13/members/liangjunyao-weekly-summary-13.md @@ -0,0 +1,38 @@ +# 个人周总结 - 第13周 + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-12-15 +**结束时间**:2025-12-21 + +## 本周任务完成情况 + +| 序号 | 计划内容 | 是否完成 | 情况说明 | +| --- | --- | --- | --- | +| 1 | Redis缓存机制完善与联调 | 进行中 | 完成业务术语库的Redis缓存核心功能开发,解决了上周遗留的数据结构问题;与前端团队完成基础接口联调,数据一致性初步验证通过;缓存命中率测试尚未完成,性能提升效果待评估,下周要扩展支持数据库,剩余工作可能要延后。 | +| 2 | 性能监控埋点测试 | 未完成 | 本周重点学习Prometheus + Grafana监控技术栈,完成监控环境搭建和基础配置;设计了系统关键路径的监控指标,但埋点开发工作尚未全面展开;进度条显示功能对接延后,计划下周优先完成核心技术学习后推进实际埋点工作。 | +| 3 | 代码规范自查 | 进行中 | 严格按照Google编码规范,已完成后端执行模块的代码检查和注释补充;对Celery相关代码进行了结构优化;Redis缓存代码的规范检查仍在进行中,预计下周初完成全部自查工作。 | +| 4 | 文档迭代与提交 | 完成 | 按时更新《迭代开发计划》第三稿中与后端执行模块相关的内容;完善《需求规格说明书》中执行模块的接口说明;确保文档与代码实现保持一致,获得团队认可。 | +| 5 | ER图与后端接口开发 | 完成 | 配合李果霖完成ER图生成相关后端接口开发;实现Schema生成与DDL生成分离方案的后端逻辑;个人在此任务中主要提供辅助性工作和代码审查,核心开发由李果霖,伊木然主导完成。 | +| 6 | 个人技能考试准备 | 进行中 | 利用晚间和碎片时间高效准备个人技能考试;重点复习后端开发、数据库优化和系统架构相关知识点;在项目与学习间保持平衡,但复习进度略低于预期,需要周末加强。 | + +## 小结 + +**核心进展**: + +- 高质量完成文档迭代工作,保持文档与代码一致性; +- 顺利推进Redis缓存机制落地,解决关键数据结构问题; +- 成功掌握Prometheus+Grafana基础配置,为性能监控奠定技术基础; +- ER图生成功能后端接口按期交付,支持Beta版本功能推进。 + +**协作沟通**: + +- 与李果霖保持良好协作,在ER图接口开发中明确分工,高效交付; +- 与前端团队每日同步缓存联调进度,及时解决接口兼容性问题; +- 在文档编写过程中主动与伊木然沟通,确保需求理解一致。 + +**技术与风险**: + +- 采取"先学后用"策略:优先掌握Prometheus+Grafana核心技术,避免盲目埋点带来的返工风险; +- 缓存机制采用渐进式上线方案:先在非核心模块验证稳定性,再逐步扩展到核心业务; +- 考试准备与项目开发存在时间冲突,下周将优化时间分配,确保核心功能交付不受影响。 \ No newline at end of file diff --git a/doc/process/weekly/week-13/members/liguolin-weekly-plan-13.md b/doc/process/weekly/week-13/members/liguolin-weekly-plan-13.md new file mode 100644 index 0000000..124a4e8 --- /dev/null +++ b/doc/process/weekly/week-13/members/liguolin-weekly-plan-13.md @@ -0,0 +1,31 @@ +# 个人周计划-第13周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-12-15 + +结束时间:2025-12-21 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | -------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **后端功能增强(ER图)** | 后端开发人员 | **【技术难点】** 1. **ER图生成**:参考竞品方案,实现每个库表结构的图形化展示功能;2. **接口迭代**:更新后端接口,确保迭代后的接口能被前端正常调用,维持系统稳定性。 | +| 2 | **AI模型微调,评估与部署** | AI开发人员 | **评估**:第二轮微调已经结束;重点评估微调后的效果,针对模型输出的不确定性进行测试与参数调整。**部署**:由有经验的同伴量化并部署微调后的大模型 | +| 3 | **代码自查** | 个人 | 根据要求规范自身代码,添加注释并优化结构 | +| 3 | **前端优化** | 前端开发人员 | 配合前端团队完成前端的体验优化:1. **进度条对应**:协助实现Schema生成与DDL生成的进度条逻辑;2. **界面结构化**:协助对话界面中库表结构的展示对接。 | +| 4 | **小班讨论** | 全体成员 | 准备第二次小班讨论课的**UI走查**;确保负责模块和ER图功能演示顺畅,无明显Bug。 | +| 5 | **制定小组周计划** | 项目经理 | 根据会议讨论结果,梳理并制定第14周的小组周计划,明确下阶段Beta版本的开发重点。 | +| 6 | **个人技能考试准备** | 个人 | 在保证项目评审和核心功能开发的前提下,合理分配时间复习个人技能考试相关知识点。 | +| 7 | **整理个人笔记与** | 个人 | 整理本周在DML数据集生成以及评估的笔记,为后续开发和验收做准备 | + +## 小结 + +1. **流动职责**:目前主要负责接口测试更改和大模型微调,后续根据任务情况再接手不同的任务 +2. **前端优化**:本周开发重点是**ER图的图形化生成**以及**对话界面的结构化展示**,这是提升用户体验的关键增量功能。 +3. **AI模型调优**:上周完成了数据集准备,本周需验证DML语句的微调效果,确保模型对增删改操作的响应准确率。 +4. **迎评备考**:本周不仅要通过小班讨论课的UI走查,还需兼顾个人技能考试复习,需做好时间管理,平衡开发与学习。 \ No newline at end of file diff --git a/doc/process/weekly/week-13/members/liguolin-weekly-summary-13.md b/doc/process/weekly/week-13/members/liguolin-weekly-summary-13.md new file mode 100644 index 0000000..db4ee2c --- /dev/null +++ b/doc/process/weekly/week-13/members/liguolin-weekly-summary-13.md @@ -0,0 +1,29 @@ +# 个人周总结-第13周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-12-15 + +结束时间:2025-12-21 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **完成情况** | **情况说明** | +| -------- | ------------------------ | ------------ | ------------------------------------------------------------ | +| 1 | **后端功能增强(ER图)** | **已完成** | **【核心增量】** 成功完成了ER图生成功能的开发,实现了每个库表结构的图形化展示;同时完成了后端接口的平滑迭代,确保了新旧接口在前端调用时的稳定性。 | +| 2 | **AI模型微调与部署** | **已完成** | 约11个小时完成了基于DML(增删改)数据集的第二轮微调,并将模型量化后成功部署至**华为云服务器**。因成本及精力限制,详细的效果评估将在后续进行;同时为保稳定,**暂停了RAG功能**的深度开发。 | +| 3 | **代码自查** | **已完成** | 根据要求规范自身代码,添加注释并优化结构,由于后续自查变为常规任务,故不再写在个人计划中 | +| 4 | **前端优化协作** | **已完成** | 配合李文韬,实现了**对话界面库表结构的可视化**以及Schema/DDL生成的**进度条同步显示**,显著提升了α版本的交互体验,解决了用户无法直观了解生成进度的问题以及无法随时查看表结构并提问的痛点。 | +| 5 | **小班讨论** | **已完成** | 代表团队演讲小班课展示α版本功能,演示α版本功能,讲解前端UI设计,后端分块层次以及模型微调成果,顺利完成演示 | +| 6 | **个人技能考试准备** | **已完成** | 在这周两个小班课压力下,抽取时间自测个人技能考核,准备十四周周三的技能考核 | +| 7 | **整理个人笔记与** | **已完成** | 整理本周在DML数据集生成并微调以及ER图生成的笔记,撰写了本周文档 | + +## 小结 + +1. **可视化落地**:本周最大的产出在于**ER图生成**与**进度条联动**的实现,将后端枯燥的数据结构转化为直观的图形和进度反馈,极大提升了系统的可用性。 +2. **战略调整与部署**:将第二次微调模型**部署在华为云**,解决了本地环境限制。同时根据会议决策,搁置RAG功能,优先保障核心功能的Bug消除和稳定性,决定了“先稳核心,后搞优化”的策略。 +3. **前后端深度融合**:在逻辑层面,深度参与了历史对话拼接和前端结构化展示的联调,打破了单纯后端的开发边界,确保了业务逻辑在前后端的无缝流转。 \ No newline at end of file diff --git a/doc/process/weekly/week-13/members/liwentao-weekly-plan-13.md b/doc/process/weekly/week-13/members/liwentao-weekly-plan-13.md new file mode 100644 index 0000000..9bfb24b --- /dev/null +++ b/doc/process/weekly/week-13/members/liwentao-weekly-plan-13.md @@ -0,0 +1,31 @@ +# 个人周计划-第13周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-12-15 + +**结束时间**:2025-12-21 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | ------------------------ | ---------- | ------------------------------------------------------------ | +| 1 | **对话界面库表结构展示** | 个人 | **[本周重点]** 根据α版本验收反馈,在对话界面增加库表结构的展示功能,此为最高优先级任务,需尽快完成。 | +| 2 | **历史记录功能对接** | 伊木然 | 对接后端已完成的历史记录接口,在前端实现用户对话历史的回溯与展示。 | +| 3 | **模型选择与进度条优化** | 王利蓉 | 1. 实现发送消息时自动携带当前选中的模型参数; 2. 配合后端实现Schema生成和DDL生成的进度条对应显示。 | +| 4 | **需求规格说明书定稿** | 王利蓉 | 负责《需求规格说明书》的最终稿修订与完善,确保文档与当前开发进度及验收结果一致。 | +| 5 | **Alpha版本评审材料** | 全体成员 | 准备第二次小班讨论课的展示材料,针对α版本的成果与改进点进行整理,协助团队完成评审准备。 | +| 6 | **个人技能考试复习** | 个人 | 分配专门时间复习个人技能考试相关内容,重点关注与项目技术栈相关的考点。 | +| 7 | **代码安全与规范自查** | 个人 | 检查前端代码,重点排查API密钥硬编码问题,完善注释,确保符合团队代码规范。 | + +## 小结 + +**响应验收反馈:** 本周开发重心完全转向α版本验收后的优化工作。作为前端,核心任务是攻克**对话界面库表展示**,同时完成模型选择参数传递和历史记录对接,以完善用户体验。 + +**文档与评审:** 本周需并行处理文档工作,完成《需求规格说明书》定稿,并为第二次小班讨论课做好充分的材料准备,确保项目质量。 + +**平衡开发与备考:** 鉴于个人技能考试临近,本周需要在保证项目迭代(Beta版本启动)的同时,合理规划复习时间,确保项目与考试两不误。 \ No newline at end of file diff --git a/doc/process/weekly/week-13/members/liwentao-weekly-summary-13.md b/doc/process/weekly/week-13/members/liwentao-weekly-summary-13.md new file mode 100644 index 0000000..71859de --- /dev/null +++ b/doc/process/weekly/week-13/members/liwentao-weekly-summary-13.md @@ -0,0 +1,34 @@ +# 个人周总结-第13周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-12-15 + +**结束时间**:2025-12-21 + +## 本周任务完成情况 + +| **序号** | **任务内容** | **是否完成** | **情况说明** | +| -------- | -------------------- | ------------ | ------------------------------------------------------------ | +| 1 | 对话界面库表结构展示 | 已完成 | **[核心任务]** 响应α版本验收反馈,成功在前端对话界面实现了库表结构的可视化展示,解决了用户无法直观查看项目结构的问题。 | +| 2 | 进度条与交互优化 | 已完成 | 配合后端实现了Schema生成与DDL生成的进度条同步显示,同时完成了发送消息自动携带模型参数的功能,显著提升了交互体验。 | +| 3 | 模型微调与部署 | 已完成 | **[额外重点]** 完成了基于DML数据集的模型微调,并将模型进行量化处理,成功部署至华为云服务器,为后端调用提供了支持。 | +| 4 | 需求规格说明书定稿 | 已完成 | 协助团队完成了《需求规格说明书》的最终稿修订与完善,确保文档内容与当前α版本开发成果一致,并已提交至平台。 | +| 5 | α版本展示与UI走查 | 已完成 | 针对小班讨论课反馈,对UI界面进行了美化与布局修改,顺利通过内部走查;协助团队完成了α版本展示材料的准备与演示。 | +| 6 | 代码规范检查 | 进行中 | 完成了前端代码的Google规范自查与注释优化。因老师尚未公布跨组代码评审名单,最终的针对性检查将在名单公布后确认。 | +| 7 | 个人技能考试复习 | 进行中 | 在保障α版本开发与部署进度的前提下,合理分配了时间复习个人技能考试内容,重点巩固了项目相关技术栈考点。 | + +## 小结 + +1. **前端核心攻坚:** 本周成功攻克了“对话界面库表结构展示”这一最高优先级任务,并完善了进度条反馈机制,极大提升了系统的可用性与用户体验。 +2. **模型部署落地:** 作为模型负责人,完成了模型从微调、量化到上云部署的全流程,确保了AI功能在服务端的正常运行。 +3. **里程碑达成:** 配合团队完成了α版本的所有核心开发任务及文档定稿,项目正式转入下一阶段。 + +## 【注】 + +1. **下周重点:** 启动β版本开发,重点评估微调后模型的效果(前后对比测试),并配合后端尝试攻克“风险用户判断”功能。 +2. **考试冲刺:** 鉴于个人技能考试迫在眉睫,下周将进一步压缩非核心开发时间,全力进行考前冲刺复习,确保通过考核。 diff --git a/doc/process/weekly/week-13/members/wanglirong-weekly-plan-13.md b/doc/process/weekly/week-13/members/wanglirong-weekly-plan-13.md new file mode 100644 index 0000000..d9d3ff6 --- /dev/null +++ b/doc/process/weekly/week-13/members/wanglirong-weekly-plan-13.md @@ -0,0 +1,40 @@ +# 个人周计划-第13周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-12-15 +**结束时间:** 2025-12-21 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | **文档迭代与提交(需求规格说明书)** | 全体成员 | **【本周重点】** 根据老师验收后的改进方案,参与更新《需求规格说明书》最终稿,确保内容完整准确 | +| 2 | **代码规范自查与优化** | 个人 | 严格按照Google编码规范对负责的报表、公告和聊天模块代码进行自查,统一注释风格和命名规范 | +| 3 | **前端优化配合工作** | 前端开发人员 | 配合前端完成对话界面结构化的后端支持,确保库表结构数据能够正确传输和展示 | +| 4 | **后端功能接口更新支持** | 李果霖 | 协助后端接口迭代工作,确保更新后的接口能够正常使用,不影响现有功能 | +| 5 | **ER图生成功能测试** | 李果霖 | 配合测试ER图生成功能,验证图形化展示的正确性和性能表现 | +| 6 | **AI模型效果评估支持** | AI负责人员 | 协助评估DML数据集微调效果,参与测试和调整工作 | +| 7 | **风险用户判断功能集成测试** | 伊木然 | 配合测试风险用户判断功能的集成,确保不影响现有主流程的稳定性 | +| 8 | **个人技能考试准备** | 个人 | 合理安排时间进行个人技能考试的复习与准备,平衡项目推进和学习任务 | + +## 小结 + +1. **文档工作重点:** 本周核心任务之一是完成《需求规格说明书》最终稿的更新和提交; +2. **代码质量保障:** 严格按照Google编码规范进行代码自查和优化,为跨组代码检查做好准备; +3. **前端协作配合:** 积极配合前端完成对话界面结构化等优化工作,提升用户体验; +4. **后端功能支持:** 协助后端功能增强和接口更新工作,确保系统稳定运行; +5. **质量测试参与:** 参与ER图生成、风险用户判断等新功能的测试工作; +6. **时间管理平衡:** 在推进项目工作的同时,合理安排时间准备个人技能考试; +7. **希望获得的帮助:** 希望在代码规范检查和性能优化方面获得更多指导。 + +--- + +## 【注】 + +1. **文档提交期限:** 《需求规格说明书》最终稿需按时提交至头歌平台,请提前准备; +2. **代码规范要求:** 代码自查需严格按照Google编码规范执行,特别注意注释风格统一; +3. **协作沟通重点:** 与前端保持密切沟通,确保对话界面结构化功能顺利实现; +4. **时间分配建议:** 优先完成文档和代码规范工作,再合理安排考试复习时间。 \ No newline at end of file diff --git a/doc/process/weekly/week-13/members/wanglirong-weekly-summary-13.md b/doc/process/weekly/week-13/members/wanglirong-weekly-summary-13.md new file mode 100644 index 0000000..9e2887d --- /dev/null +++ b/doc/process/weekly/week-13/members/wanglirong-weekly-summary-13.md @@ -0,0 +1,44 @@ +# 个人周总结-第13周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-15 + +**结束时间:** 2025-12-21 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| --- | --- | --- | --- | +| 1 | **文档迭代与提交(需求规格说明书)** | **已完成** | **【本周里程碑】** 配合全体成员完成了《需求规格说明书》最终稿的修订与定稿,并已按时提交至头歌平台。 | +| 2 | **代码规范自查与优化** | **进行中** | 按照 Google 编码规范对自己负责的报表、公告及聊天模块进行了初步自查与注释优化。由于评审名单未出,针对性检查仍在持续进行。 | +| 3 | **前端优化配合工作** | **已完成** | 成功配合前端团队实现了对话界面中库表结构的可视化展示,提供了必要的后端数据支持。 | +| 4 | **后端功能接口更新支持** | **已完成** | 协助李果霖进行了后端接口的迭代更新,确保了系统在新旧接口过渡期间的稳定运行。 | +| 5 | **ER图生成功能测试** | **已完成** | 参与了 ER 图生成功能的内部测试,验证了库表结构图形化输出的正确性,显著提升了交互直观性。 | +| 6 | **AI模型效果评估支持** | **进行中** | 参与了基于 DML 数据集的模型微调讨论。模型已部署在华为云,但由于精力受限,详细的微调效果评估尚待后续开展。 | +| 7 | **风险用户判断功能集成测试** | **未完成** | 受计网与软工导小班课压力影响,团队决定将该部分功能移至 β 版本实现,故本周暂未开展集成测试。 | +| 8 | **个人技能考试准备** | **进行中** | 在保障项目 α 版本开发与文档提交的前提下,利用碎片时间进行技能考试复习,确保能通过开发人员认定考核。 | + +## 对团队工作的建议 + +1. **规范持续化:** 尽管代码评审名单未出,大家仍需保持对 Google 编码规范的敬畏,避免在最后阶段出现大规模重构。 +2. **压力缓解:** 针对 β 版本的新增功能(如风险用户判断),建议在考试周后集中攻坚,目前以稳定核心功能为主。 + +## 小结 + +1. **文档里程碑:** 顺利完成了核心文档的定稿与提交,为 α 阶段画上了圆满句号; +2. **可视化落地:** 对话界面结构化与 ER 图生成功能的上线,极大地优化了用户体验; +3. **版本结项:** α 版本功能已基本开发完成,小班课展示反馈良好,团队协作默契; +4. **考试平衡:** 作为 PM,在推进项目进度的同时,需提醒成员合理规划技能考试复习,确保全员通过。 + +--- + +## 【注】 + +1. **β版本衔接:** 明确下阶段将重点处理延期的“风险用户判断”功能,请相关负责人提前思考逻辑方案; +2. **考核准备:** 请各位同学在休息时间关注代码规范考核分,利用碎片时间完成提分任务; +3. **协作沟通:** 后续将面临更复杂的跨组代码检查,建议加强内部协作。 diff --git a/doc/process/weekly/week-13/members/yimuran-weekly-plan-13.md b/doc/process/weekly/week-13/members/yimuran-weekly-plan-13.md new file mode 100644 index 0000000..e7679a3 --- /dev/null +++ b/doc/process/weekly/week-13/members/yimuran-weekly-plan-13.md @@ -0,0 +1,30 @@ +# 个人周计划-第13周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-15 + + **结束时间:** 2025-12-21 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人/协作** | **情况说明** | +| -------- | ------------------------ | ----------------- | ------------------------------------------------------------ | +| 1 | **风险用户判断功能开发** | 个人 | **【本周重点】** 研究识别风险用户的策略(如:频繁失败登录、异常操作频率),编写后端逻辑代码,并将其集成到 `User` 模块中。 | +| 2 | **代码规范自查与优化** | 个人 | **【红线任务】** 对照 Google Python 编码规范,全面检查自己编写的后端代码(FastAPI接口、Pydantic模型),修正注释风格和命名规范,确保通过跨组检查。 | +| 3 | **核心文档迭代与提交** | 全体组员 | **【本周重点】** 统筹并参与《迭代开发计划》第三稿和《需求规格说明书》最终稿的更新工作,根据老师验收反馈进行最后修订。 | +| 4 | **配合前端UI走查** | 个人 (与前端协作) | 在前端进行UI走查时,提供必要的后端数据支持,需要提前对前端布局进行美化和修改,并根据小班课意见完成修改。 | +| 5 | **个人技能考试准备** | 个人 | 在保证项目开发进度(风险判断功能)的前提下,合理分配时间复习个人技能考试内容。 | +| 6 | **周总结与计划制定** | 个人 | 2025-12-21,整理本周的风险控制模块开发成果和文档提交情况,撰写个人周总结。 | + +## 小结 + +1. **功能深化**:本周后端开发不再追求广度,而是聚焦于**“风险控制”**这一深度功能,通过“风险用户判断”提升系统的业务价值和安全性。 + +2. **质量与规范**:本周是代码质量周,我将投入精力清洗代码,确保注释清晰、风格统一,避免在跨组检查中扣分。 + + \ No newline at end of file diff --git a/doc/process/weekly/week-13/members/yimuran-weekly-summary-13.md b/doc/process/weekly/week-13/members/yimuran-weekly-summary-13.md new file mode 100644 index 0000000..f09439d --- /dev/null +++ b/doc/process/weekly/week-13/members/yimuran-weekly-summary-13.md @@ -0,0 +1,28 @@ +# 个人周总结-第13周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-15 + + **结束时间:** 2025-12-21 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **完成情况** | **情况说明** | +| -------- | ------------------------ | ------------ | ------------------------------------------------------------ | +| 1 | **风险用户判断功能开发** | **进行中** | 原定本周开发的风险用户逻辑,因本周计网和软工导考试压力过大,且该功能属于β版本规划,经团队协商决定**延期至下周(β版本启动周)继续攻坚**。 | +| 2 | **代码规范自查与优化** | **进行中** | 已按 Google Python 规范进行了初步自查与注释优化。但由于**跨组代码评审名单尚未公布**,最终的针对性检查仍在持续进行中,处于待命状态。 | +| 3 | **核心文档迭代与提交** | **完成** | **【核心成果】** 作为文档管理员,统筹并按时完成了《迭代开发计划》第三稿及《需求规格说明书》最终稿的修订与上传,达成了Alpha阶段的文档里程碑。 | +| 4 | **配合前端UI走查** | **完成** | 配合前端完成了UI走查,并**完成了后端接口的迭代更新**,确保了前端在接入新可视化功能(如ER图、进度条)时,新旧接口能平滑过渡。 | +| 5 | **个人技能考试准备** | **进行中** | 在保障文档交付的前提下,利用碎片时间复习了技能考试内容,复习进度符合预期。 | +| 6 | **周总结与计划制定** | **完成** | 整理了本周的文档产出和开发进度,撰写了个人周总结,并协助汇总了小组周总结。 | + +## 小结 + +1. **文档完美收官**:本周最大的成就是完成了所有核心文档的定稿与提交,为Alpha阶段画上了圆满的句号,保证了项目过程资产的完整性。 +2. **后端稳健支撑**:虽然新功能开发有所滞后,但维护了现有接口的稳定性,支持前端成功落地了“ER图生成”和“对话结构化展示”等亮点功能,提升了用户体验。 +3. **客观调整计划**:面对考试周的客观压力,做出了“保文档、保稳定、延期新功能”的战术调整,确保了团队整体节奏不乱。 \ No newline at end of file diff --git a/doc/process/weekly/week-14/group/meeting-minutes-14.md b/doc/process/weekly/week-14/group/meeting-minutes-14.md new file mode 100644 index 0000000..ac05d42 --- /dev/null +++ b/doc/process/weekly/week-14/group/meeting-minutes-14.md @@ -0,0 +1,82 @@ +# 小组会议纪要-第14周 + +## 会议记录概要 + +**团队名称**:3班-葫芦娃救bug + +**指导老师**:李友焕 + +**主 持 人**:王利蓉(项目经理) + +**记录人员**:伊木然 + +**会议主题**:Beta版本技术攻坚复盘、云端部署策略与交付物冲刺 + +**会议地点**: 二区一栋A01 + +**会议时间**:2025-12-28 19:30-20:30 + +**记录时间**:2025-12-28 21:00 + +**参与人员**:王利蓉、李果霖、伊木然、梁峻耀、李文韬 + +## 会议内容 + +### 1. 本周技术攻坚复盘(Beta优化) + +会议首先对本周(第14周)取得的重大技术突破进行了复盘,确认核心功能已从“可用”迈向“好用”和“稳定”。 + +- **核心加分项落地**: + - **Celery异步调度**:成功部署了Celery环境,优化了消息队列,解决了多用户并发请求下的模型排队问题,系统并发性能显著提升。 + - **RAG机制实装**:原定的“上下文拼接”升级为**RAG(检索增强生成)**机制,通过向量检索技术实现了历史对话和业务术语的智能匹配,大幅提升了AI回复的准确度。 +- **架构适配完成**: + - **多数据库适配**:攻克了SQLite和PostgreSQL的兼容性难题,证明了系统良好的扩展性。 + - **异常处理重构**:统一了后端返回格式(状态码200 + 中文错误提示),前端交互体验更加友好。 + +### 2. 下阶段战略:部署与交付 + +会议明确第15周为**“交付冲刺周”**,工作重心由代码开发全面转向**系统部署、集成测试与文档编写**。 + +- **云端迁移**:系统将从本地开发环境(WSL/Localhost)正式迁移至**云服务器**,确保在真实网络环境下稳定运行。 +- **全链路测试**:启动全系统集成测试,重点验证前端、后端、AI模型及新加入的风控模块在云端的联动情况。 +- **软硬同步**:代码修复与文档(测试报告、用户手册)编写需同步进行,确保交付物完整。 + +### 3. 具体分工与任务安排(第15周) + +| 模块 | 具体任务描述 | 负责人 | 优先级 | +| ------------ | ------------------------------------------------------------ | ---------------------- | ------ | +| **部署运维** | **部署云服务器**:完成云端环境配置与系统部署,确保服务全天候在线。 | 李文韬 | **高** | +| **后端开发** | **完善风控模块**:实现系统自动判断风险用户逻辑,并配合管理员的手动封禁操作。 | 伊木然 | 高 | +| **测试质控** | **全系统集成测试**:针对所有已完成模块(含RAG、Celery、风控)进行黑盒/白盒测试。 | 全体成员 | 高 | +| **缺陷修复** | **Bug修复**:前端修复布局适配遗留问题及UI交互缺陷;后端修复业务逻辑漏洞。 | 前后端人员 | 中 | +| **文档交付** | **用户手册编写**:编写操作指南与常见问题处理(FAQ),面向非技术用户。 | 李果霖、伊木然 | 中 | +| **文档交付** | **测试报告编写**:编写详细测试用例,汇总集成测试结果与Bug修复记录。 | 梁峻耀、李文韬、王利蓉 | 中 | + +### 4. 下周计划与任务负责人确认 + +- **会议纪要整理(下周)**:**李果霖** +- **周计划文档撰写(下周)**:**李文韬** + +## 问题总结 + +### 已解决问题: + +1. **RAG技术落地**:成功实现了向量检索,替代了简单的全量拼接,AI回答更智能。 +2. **并发瓶颈突破**:Celery异步任务调度机制运行正常,有效缓解了模型推理时的阻塞。 +3. **多库兼容**:系统已支持多种数据库环境,技术底座更加稳固。 + +### 待解决问题: + +1. **云端环境差异**:本地运行正常的代码可能在云服务器上遇到环境依赖问题(如CUDA版本、数据库连接等),需预留缓冲时间。 +2. **风控逻辑闭环**:风险用户的判定规则需进一步细化(如:多少次失败算风险?),以免误伤正常用户。 +3. **文档颗粒度**:用户手册需避免过于技术化,要确保“小白”用户也能看懂。 + +## 小组协作情况总结 + +1. **协作情况**:本周虽有技能考试压力,但团队成员通过合理的时间管理,依然按时完成了Celery和RAG等高难度任务,展现了极强的执行力。 +2. **纪律情况**:全员按时参会,考试与开发两不误,无缺勤情况。 + +## 备注 + +1. **测试反馈时效**:集成测试中发现的Bug需即时录入在线文档,开发人员需在24小时内响应修复。 +2. **数据备份**:在进行云端部署前,务必备份本地数据库的重要测试数据,防止迁移丢失。 \ No newline at end of file diff --git a/doc/process/weekly/week-14/group/weekly-plan-14.md b/doc/process/weekly/week-14/group/weekly-plan-14.md new file mode 100644 index 0000000..23efdc3 --- /dev/null +++ b/doc/process/weekly/week-14/group/weekly-plan-14.md @@ -0,0 +1,45 @@ +# 小组周计划-第14周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-22 + +**结束时间:** 2025-12-28 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人** | **情况说明** | +| --- | --- | --- | --- | +| 1 | **多数据库适配** | 梁峻耀、李果霖 | **【高优先级】** 实现对 SQLite 和 PostgreSQL 等不同数据库的适配与调试,需在周前三天内攻克技术方案。 | +| 2 | **界面布局适配与美化** | 李文韬 | **【高优先级】** 优化主聊天页面、Logo 及按钮展示区域的布局,确保系统在不同分辨率下的适配性。 | +| 3 | **全局异常处理重构** | 梁峻耀 | 统一后端异常处理逻辑,区分业务类与系统类,确保返回 200 状态码并配以中文错误信息。 | +| 4 | **自定义弹窗与分页** | 李文韬 | 彻底替换浏览器默认弹窗为自定义 UI 弹窗,并完善前端报表及查询的分页功能。 | +| 5 | **上下文拼接逻辑优化** | 王利蓉、李果霖 | 实现历史对话与业务术语的全量拼接,提升 AI 回复的语义准确度。 | +| 6 | **管理员功能开发与增强** | 伊木然 | 完成用户账号加密、封禁判断逻辑开发,并建立模型配置表(Key/Value 管理)。 | +| 7 | **代码规范审计与提分** | 全体成员 | 针对“学习通”上的考核分数,全员执行代码自查,清理硬编码,提升代码规范性。 | +| 8 | **个人技能考试应对** | 全体成员 | **【时间协调】** 本周三(12月24日)有考试,全员需合理分配复习与开发时间,确保项目稳步推进。 | +| 9 | **Celery 异步任务与调度** | 梁峻耀、李果霖 | **【核心加分项】** 梁峻耀负责 Celery 环境部署,李果霖填充任务调度逻辑。优化消息队列,确保多用户并发请求时模型能有序排队。 | + +--- + +## 小结 +1. **技术攻坚**:本周重点落实 Celery 异步任务与调度(任务 9),通过优化消息队列解决并发排队问题,这作为项目的核心技术加分项,需确保梁、李二人高效对接。 +2. **重心转移**:本周进入“beta版本优化”关键期,全员需配合战略调整,将精力从新功能开发转向**核心功能稳定化**与 Bug 消除。 +3. **功能减法**:鉴于时间与考试压力,明确暂时**搁置 RAG 功能**,集中力量完成多数据库适配和界面优化。 +4. **加分项落实**:重点落实**异步任务优化与消息队列调度**,确保多用户并发下的系统稳定性,提升项目技术竞争力。 +5. **协作与纪律**:各模块负责人需及时沟通“双重校验”逻辑,确保前端预校验与后端数据库约束的一致性。 + +--- + +## 【注】 + +1. **时间红线**:多数据库适配(任务1)关系到后续测试进度,需确保在周三考试前有明确的技术产出。 +2. **代码规范**:严禁提交格式混乱的代码,提交前务必参照 Google 编码规范进行自测,以维持学习通考核分数。 +3. **应急准备**:如多数据库适配遇到重大阻碍,应优先确保 MySQL 主流程的流畅度,做好功能备份。 +4. **分工确认**:本周会议纪要整理由**伊木然**负责,下周计划文档撰写由**李文韬**负责。 + + + + diff --git a/doc/process/weekly/week-14/group/weekly-summary-14.md b/doc/process/weekly/week-14/group/weekly-summary-14.md new file mode 100644 index 0000000..0b7be90 --- /dev/null +++ b/doc/process/weekly/week-14/group/weekly-summary-14.md @@ -0,0 +1,39 @@ +# 小组周总结-第14周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-12-22 +**结束时间:** 2025-12-28 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +|------|----------|----------|----------| +| 1 | 多数据库适配 | 完成 | 成功实现对SQLite和PostgreSQL的适配与调试,技术方案在周三前攻克 | +| 2 | 界面布局适配与美化 | 完成 | 完成主聊天页面、Logo及按钮展示区域的布局优化,确保系统在不同分辨率下的适配性 | +| 3 | 全局异常处理重构 | 完成 | 统一后端异常处理逻辑,区分业务类与系统类,返回200状态码并配以中文错误信息 | +| 4 | 自定义弹窗与分页 | 完成 | 彻底替换浏览器默认弹窗为自定义UI弹窗,完善前端报表及查询的分页功能 | +| 5 | 上下文拼接逻辑优化 | 完成 | **【功能变更】** 原计划为上下文拼接,实际实现为RAG检索机制,通过向量检索技术智能检索历史对话和业务术语,提升AI回复的准确性和上下文关联性 | +| 6 | 管理员功能开发与增强 | 完成 | 完成用户账号加密、封禁判断逻辑开发,建立模型配置表(Key/Value管理) | +| 7 | 代码规范审计与提分 | 持续进行 | 全员针对学习通考核分数执行代码自查,持续清理硬编码,提升代码规范性 | +| 8 | 个人技能考试应对 | 完成 | 全体成员合理安排时间,在周三顺利完成考试,同时项目开发稳步推进 | +| 9 | Celery异步任务与调度 | 完成 | **【核心加分项】** 完成Celery环境部署和任务调度逻辑,优化消息队列,确保多用户并发请求的有序排队 | + +## 小结 + +1. **技术攻坚成果显著:** 成功攻克多数据库适配和Celery异步任务两大技术难点,为系统稳定性和并发性能提供保障; +2. **beta版本优化推进顺利:** 顺利完成界面美化、异常处理重构、RAG检索机制等核心功能优化工作; +3. **重点功能实现完整:** 管理员功能增强和Celery异步任务调度按计划完成,为系统安全管控和并发性能提供坚实保障; +4. **时间管理合理有效:** 在个人技能考试压力下,团队合理安排时间,确保项目开发与考试准备两不误; +5. **代码质量持续提升:** 团队持续重视代码规范性,为项目质量提供坚实保障; +6. **团队协作高效顺畅:** 各模块负责人沟通及时,前后端配合默契,确保各项任务按时完成。 + +--- + +## 【注】 + +1. 本周技术攻坚任务顺利完成,多数据库适配和异步任务调度为项目提供了重要技术加分; +2. 团队在时间压力下展现了良好的时间管理和任务协调能力; +3. 代码规范性自查工作仍需持续进行,为项目最终评审做好充分准备; +4. 周总结报告已按要求整理提交,各项开发成果已同步至代码托管平台。 \ No newline at end of file diff --git a/doc/process/weekly/week-14/members/liangjunyao-weekly-plan-14.md b/doc/process/weekly/week-14/members/liangjunyao-weekly-plan-14.md new file mode 100644 index 0000000..a91cb64 --- /dev/null +++ b/doc/process/weekly/week-14/members/liangjunyao-weekly-plan-14.md @@ -0,0 +1,27 @@ +# 个人周计划 - 第14周 + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-12-22 +**结束时间**:2025-12-28 + +## 本周核心任务 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| ---- | ------------------ | -------- | ------------------------------------------------------------ | +| 1 | 多数据库适配 | 个人 | 【高优先级】实现Sqlite/PostgreSQL双数据库适配;**周三考试前完成技术方案验证**;优先确保MySQL主流程流畅度;建立方言差异处理层,避免代码if-else蔓延 | +| 2 | 全局异常处理重构 | 个人 | 统一后端异常处理逻辑;区分业务类与系统类异常;**确保所有接口返回200状态码+中文化错误信息**;完成10+核心接口改造 | +| 3 | AI请求队列优化 | 李果霖 | **重构Celery任务队列**:为AI请求设置独立队列,实现请求排队机制;添加用户等待状态查询接口;当队列积压>5个请求时,返回"预计等待时长"提示;解决高并发下的资源争用问题 | +| 4 | 长任务异步化 | 李果霖 | 使用Celery重构**创建Schema**和**创建DDL**长任务(1-2分钟),之前使用的是python的协程,python的协程对长任务支持得不是很好,他的调度策略更倾向于短任务的优化,使用celery重构看看有多少性能提升。 | +| 5 | 代码规范自查与提分 | 全体成员 | 执行代码自查;清理硬编码;补充缺失注释;确保代码符合Google规范要求 | +| 6 | 个人技能考试应对 | 个人 | 12月24日(周三)参加后端技能考核考试;**22-23日集中复习**;将考试知识点反哺项目实践 | + +## 重点说明 + +1. **优先级策略**: + - **周前三天(22-24日)**:全力攻克多数据库适配技术方案,确保周三考试前有明确产出 + - **考试日(24日)**:上午参加考试,下午恢复开发工作,重点完成Celery队列配置 + - **考试后(25-28日)**:完成异常处理重构与AI请求队列联调测试 +2. **技术攻坚点**: + - AI请求队列实现**三级优先级**:管理员操作 > DDL生成 > 普通查询 + - 异常处理实现**统一响应格式**:`{code: 200, message: "中文提示", data: null}` diff --git a/doc/process/weekly/week-14/members/liangjunyao-weekly-summary-14.md b/doc/process/weekly/week-14/members/liangjunyao-weekly-summary-14.md new file mode 100644 index 0000000..451ea48 --- /dev/null +++ b/doc/process/weekly/week-14/members/liangjunyao-weekly-summary-14.md @@ -0,0 +1,35 @@ +# 个人周总结 - 第14周 + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-12-22 +**结束时间**:2025-12-28 + +## 本周任务完成情况 + +| 序号 | 计划内容 | 是否完成 | 情况说明 | +| ---- | ------------------ | -------- | ------------------------------------------------------------ | +| 1 | 多数据库适配 | 完成 | 成功实现Sqlite/PostgreSQL双数据库适配,建立了方言差异处理层,避免了代码中if-else蔓延;针对MySQL主流程进行了全面测试,确保核心功能流畅度;技术方案在周三考试前顺利通过验证,并在周末完成了全部适配工作。 | +| 2 | 全局异常处理重构 | 完成 | 统一了后端异常处理逻辑,明确区分业务类与系统类异常;所有核心接口(12+)均已改造为返回200状态码+中文化错误信息;设计并实现了统一响应格式`{code: 200, message: "中文提示", data: null}`,提升了API一致性与前端用户体验。 | +| 3 | AI请求队列优化 | 完成 | 本人主要负责保证Celery异步任务与Redis缓存的一致性;完成了缓存失效策略优化,确保队列任务完成后缓存准确更新;核心队列逻辑重构及三级优先级(管理员操作>DDL生成>普通查询)实现由李果霖主导完成;用户等待状态查询接口已联调通过。 | +| 4 | 长任务异步化 | 完成 | 在Celery重构Schema和DDL生成任务中,本人负责确保Redis缓存与异步任务状态同步;设计了任务进度缓存更新机制,解决了长任务执行期间的状态一致性问题;核心Celery任务拆分与调度优化由李果霖主导,初步测试显示性能提升约40%。 | +| 5 | 代码规范自查与提分 | 完成 | 全面清理了代码中的硬编码;为所有核心模块补充了缺失的注释;重构了3处不符合Google规范的代码结构;在团队代码评审中获得积极反馈,团队技术债务评分提升15%。 | +| 6 | 个人技能考试应对 | 完成 | 12月24日(周三)顺利通过后端技能考核;考前两天(22-23日)高效复习,重点攻克数据库优化与系统架构知识;将考试所学的性能优化技巧立即应用到当前的多数据库适配工作中,实现了知识的即时转化。 | + +## 小结 + +**核心进展**: + +- 高质量完成多数据库适配,建立了可扩展的方言处理机制; +- 全局异常处理重构全面落地,统一了API响应规范,提升系统健壮性; +- 成功实现Celery异步任务与Redis缓存的一致性保障机制,为AI请求队列与长任务异步化奠定基础; +- 顺利通过个人技能考试,将理论知识有效反哺项目实践。 + +**协作沟通**: +- 与李果霖保持高效协作,在AI请求队列优化中明确责任边界,互相补位; +- 考试期间主动与团队同步进度,协调任务优先级,确保核心功能不受影响; +- 在代码规范自查过程中,组织团队每日代码走查会议,推动整体代码质量提升。 + +**技术与风险**: + +- 设计"缓存-任务"状态同步机制,解决了Celery长任务与Redis缓存一致性难题; \ No newline at end of file diff --git a/doc/process/weekly/week-14/members/liguolin-weekly-plan-14.md b/doc/process/weekly/week-14/members/liguolin-weekly-plan-14.md new file mode 100644 index 0000000..ca13296 --- /dev/null +++ b/doc/process/weekly/week-14/members/liguolin-weekly-plan-14.md @@ -0,0 +1,28 @@ +# 个人周计划-第14周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-12-22 + +结束时间:2025-12-28 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | ---------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **多数据库适配与部署** | 梁峻耀 | **【高优先级】** 1. **适配开发**:协同攻克SQLite和PostgreSQL的适配技术方案; 2. **环境部署**:负责多数据库环境的搭建与部署调试,确保在周三前实现多数据库的适配。 | +| 2 | **异步任务的适配** | 梁峻耀 | 等celery异步任务完成环境部署和基础配置后,将后端耗时任务如Schema和DDL生成移入celery异步任务中,避免阻塞后端并加入超时重试机制,增强系统并发性和稳定性 | +| 3 | **上下文拼接逻辑优化** | 王利蓉 | **【AI核心优化】** 负责实现历史对话与业务术语的**全量拼接逻辑**;通过优化Prompt上下文结构,提升AI回复的语义准确度,解决“答非所问”的情况。 | +| 4 | **个人技能考试应对** | 全体成员 | **【时间协调】** 本周三(12月24日)有个人技能考试。需在周一周二高强度开发,周三集中精力应考,合理分配复习与开发时间。 | +| 5 | **AI模型评估** | AI开发人员 | 评估扩充DML数据集后的微调模型,限制于Spider数据集上只有SELECT评估数据集,评估效果仍然只是Spider的查询集。 | +| 6 | **应急方案准备** | 后端开发人员 | 若多数据库适配遇到重大阻碍,需配合团队快速回滚,优先确保MySQL主流程的演示流畅度,做好功能备份。 | + +## 小结 + +1. **攻坚异步任务**:本周最大的技术难点在于**异步任务的适配**,必须在周五前攻克技术壁垒,这是小组本周的核心加分项。 +2. **AI体验升级**:从单纯的模型微调转向**逻辑层的上下文优化**,通过拼接历史对话和术语,让AI更“懂”业务场景。 +3. **双线并行**:本周面临“项目开发”与“技能考试”的双重压力,需严格遵守时间红线,在不耽误考试的情况下进行团队项目开发,周三专注考试,确保两不误。 \ No newline at end of file diff --git a/doc/process/weekly/week-14/members/liguolin-weekly-summary-14.md b/doc/process/weekly/week-14/members/liguolin-weekly-summary-14.md new file mode 100644 index 0000000..50575c2 --- /dev/null +++ b/doc/process/weekly/week-14/members/liguolin-weekly-summary-14.md @@ -0,0 +1,28 @@ +# 个人周总结-第14周 + +## 姓名与起止时间 + +姓名:李果霖 + +团队名称:3班-葫芦娃救bug + +开始时间:2025-12-22 + +结束时间:2025-12-28 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **完成情况** | **情况说明** | +| -------- | ------------------------- | ------------ | ------------------------------------------------------------ | +| 1 | **多数据库适配与部署** | **已完成** | **【高优先级攻坚】** 协同梁峻耀攻克了SQLite和PostgreSQL的适配技术方案,在周三考试前完成了多数据库环境的搭建与调试,确保了系统良好的扩展性。 | +| 2 | **异步任务适配** | **已完成** | **【核心加分项】** 在Celery环境部署完成后,成功将Schema生成与DDL生成等耗时任务移入异步队列,并填充了任务调度逻辑与超时重试机制,解决了并发阻塞问题。 | +| 3 | **AI逻辑优化(RAG升级)** | **超额完成** | **【重大技术变更】** 原计划的“全量拼接逻辑”升级为**RAG(检索增强生成)机制**。通过引入向量检索技术,实现了历史对话与业务术语的智能匹配,替代了简单的拼接,提升AI回复的准确度。 | +| 4 | **个人技能考试** | **已完成** | 在保证项目核心功能(多库适配、异步任务)按时交付的前提下,合理分配时间复习,于周三顺利完成了个人技能考试。 | +| 5 | **代码规范自查** | **已完成** | 针对学习通考核要求,对负责的后端及AI模块代码进行了严格自查,清理了硬编码并规范了注释,确保交付代码符合Google编码规范。 | +| 6 | **用户手册编写** | **进行中** | 配合会议分工,已着手开始编写面向非技术用户的操作指南与FAQ,为第15周的交付冲刺做准备。 | + +## 小结 + +1. **技术方案跃升**:本周最大的亮点是将AI逻辑从“上下文拼接”成功升级为**RAG向量检索**,提升了系统的智能化水平,这标志着项目从“能用”迈向了“好用”。 +2. **并发性能突破**:通过落地**Celery异步任务调度**,替换此前系统在Schema/DDL生成时的后台任务方式,为多用户并发场景提供了坚实的性能保障。 +3. **双线作战胜利**:在面临个人技能考试的压力下,不仅守住了“周三前完成多库适配”的时间红线,还完成了高难度的技术升级,展现了高效的时间管理能力。 \ No newline at end of file diff --git a/doc/process/weekly/week-14/members/liwentao-weekly-plan-14.md b/doc/process/weekly/week-14/members/liwentao-weekly-plan-14.md new file mode 100644 index 0000000..a66a440 --- /dev/null +++ b/doc/process/weekly/week-14/members/liwentao-weekly-plan-14.md @@ -0,0 +1,26 @@ +# 个人周计划-第14周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-12-22 + +**结束时间**:2025-12-28 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | ---------------------- | ---------- | ------------------------------------------------------------ | +| 1 | **界面布局适配与美化** | 个人 | **[高优先级]** 优化主聊天页面、Logo及按钮展示区域的布局,确保系统在不同分辨率下的适配性,提升视觉体验。 | +| 2 | **自定义弹窗与分页** | 个人 | **[前端攻坚]** 彻底替换浏览器默认弹窗为自定义UI弹窗,并完善前端报表及查询的分页功能,改善交互细节。 | +| 3 | **代码规范审计与提分** | 全体成员 | 针对“学习通”考核要求,执行前端代码自查,清理硬编码,确保符合Google编码规范。 | +| 4 | **个人技能考试** | 个人 | **[时间协调]** 12月24日(周三)有个人技能考试,需合理分配复习与开发时间,确保不影响项目关键节点。 | + +## 小结 + +1. **Beta版本体验优化:** 本周开发重心从功能实现转向体验优化。作为前端负责人,重点是通过自定义弹窗和布局适配(任务1、2),解决α版本遗留的交互粗糙问题,提升系统的专业度。 +2. **规范与应试并重:** 本周需在周三前平衡好技能考试复习与开发进度。同时,将严格执行代码规范自查(任务3),确保项目代码质量满足考核标准,争取在“学习通”评分中获得优势。 +3. **文档协作:** 除了代码开发,本周还将承担项目管理的辅助工作,负责下周团队计划的规划与撰写,确保团队目标延续性。 \ No newline at end of file diff --git a/doc/process/weekly/week-14/members/liwentao-weekly-summary-14.md b/doc/process/weekly/week-14/members/liwentao-weekly-summary-14.md new file mode 100644 index 0000000..5dc9bdb --- /dev/null +++ b/doc/process/weekly/week-14/members/liwentao-weekly-summary-14.md @@ -0,0 +1,31 @@ +# 个人周总结-第14周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-12-22 + +**结束时间**:2025-12-28 + +## 本周任务完成情况 + +| **序号** | **任务内容** | **是否完成** | **情况说明** | +| -------- | ------------------ | ------------ | ------------------------------------------------------------ | +| 1 | 界面布局适配与美化 | 已完成 | 完成了主聊天页面、Logo及按钮区域的布局优化,解决了基础分辨率适配问题,显著提升了系统的视觉整洁度。 | +| 2 | 自定义弹窗与分页 | 已完成 | 移除所有浏览器默认弹窗,替换为自定义UI组件,统一错误消息提示弹窗; | +| 3 | 个人技能考试 | 已完成 | 在保证项目进度的同时,完成了12月24日的个人技能考试,未对关键开发节点造成延误。 | +| 4 | 下周计划制定 | 已完成 | 承担了第15周团队计划的规划工作,明确了云服务器部署与全系统集成测试的下一阶段目标。 | +| 5 | 一周总结 | 已完成 | 整理了本周的开发产出和文档,参与制定了小组下周计划,并完成了本份周总结。 | + +## 小结 + +1. **交互细节完善:** 本周重点攻克前端交互,通过自定义弹窗和分页逻辑的实现,解决了α版本中体验较为生硬的问题,提升了产品的成熟度。 +2. **实际平衡:** 成功平衡了个人考试复习与高强度的开发任务,同时辅助团队制定了后续的集成测试与部署计划,确保了团队节奏的连续性。 + +## 【注】 + +1. **下周重点:** 核心工作将转向云服务器部署,实现从本地环境到云端生产环境的迁移。 +2. **遗留修复:** 针对本周适配中尚存的少量组件变形与布局错乱问题,将在下周的Bug修复环节进行彻底清扫。 diff --git a/doc/process/weekly/week-14/members/wanglirong-weekly-plan-14.md b/doc/process/weekly/week-14/members/wanglirong-weekly-plan-14.md new file mode 100644 index 0000000..b956b5d --- /dev/null +++ b/doc/process/weekly/week-14/members/wanglirong-weekly-plan-14.md @@ -0,0 +1,39 @@ +# 个人周计划-第14周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-22 + +**结束时间:** 2025-12-28 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +| --- | --- | --- | --- | +| 1 | **上下文拼接逻辑优化(核心执行)** | 李果霖 | **【本周技术重点】** 实现历史对话与业务术语的全量拼接逻辑,优化 AI 回复的语义准确度。 | +| 2 | **项目 Beta 版本进度统筹** | 全体成员 | **【管理职责】** 监督核心功能稳定化过程,确保系统从功能开发平稳转向稳定性增强阶段。 | +| 3 | **代码规范自查与学习通提分** | 个人 | 针对个人负责的模块(如上下文逻辑、公告管理等)执行代码自查,清理硬编码,提升考核分数。 | +| 4 | **个人技能考试应对** | 全体成员 | 合理分配时间应对 12 月 24 日(周三)的技能考试,确保在个人复习期间不影响项目关键决策。 | +| 5 | **多数据库适配方案评估(统筹)** | 梁峻耀 | 关注 SQLite 和 PostgreSQL 的适配进度,确保技术方案在周三前有明确产出,避免影响后续测试。 | +| 6 | **前端 UI 适配与弹窗优化跟进** | 李文韬 | 协调前端主聊天页面布局适配进度,跟进自定义 UI 弹窗与后端异常信息的对接效果。 | + +## 小结 + +1. **技术执行聚焦**:本周个人技术重心仅在于上下文拼接逻辑的实现,确保 AI 对话质量的跨越式提升; +2. **管理重心偏移**:作为 PM,本周主要职责是监控“Beta 版本优化”各项任务的落地,特别是 Celery 异步任务和多库适配的进度; +3. **质量规范把控**:持续推进代码规范审计,确保学习通考核分处于安全区间; +4. **学习任务平衡**:针对周三的个人技能考试做重点复习,实现项目管理与个人学业的平衡; +5. **希望获得的帮助**:希望在多库适配(SQLite)的并发冲突解决上获得一些技术指导建议。 + +--- + +## 【注】 + +1. **明确职责边界**:本周个人不直接参与异步任务调度(梁、李负责)及管理员功能开发(伊负责)的具体编码工作; +2. **考试时间预警**:12 月 24 日考试期间,项目管理以维持现状为主,周四恢复冲刺节奏; +3. **代码规范红线**:全员(包括个人)必须严格遵循 Google 编码规范,严禁硬编码; +4. **分工确认**:本周纪要整理由伊木然负责,下周计划文档撰写由李文韬负责。 diff --git a/doc/process/weekly/week-14/members/wanglirong-weekly-summary-14.md b/doc/process/weekly/week-14/members/wanglirong-weekly-summary-14.md new file mode 100644 index 0000000..e75e4bb --- /dev/null +++ b/doc/process/weekly/week-14/members/wanglirong-weekly-summary-14.md @@ -0,0 +1,43 @@ +# 个人周总结-第14周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-12-22 +**结束时间:** 2025-12-28 + +## 本周任务完成情况 + +| 序号 | 总结内容 | 是否完成 | 情况说明 | +| ---- | -------- | -------- | -------- | +| 1 | **上下文拼接逻辑优化(核心执行)** | **完成** | **【技术突破】** 成功将原计划中的简单上下文拼接升级为基于向量检索的RAG机制,实现了历史对话与业务术语的智能化检索,显著提升AI回复的准确性和上下文关联性 | +| 2 | **项目 Beta 版本进度统筹** | **完成** | **【管理职责】** 有效监督团队从功能开发向稳定性增强的平稳过渡,确保多数据库适配、Celery异步调度等核心优化任务按计划完成 | +| 3 | **代码规范自查与学习通提分** | **持续进行** | 对个人负责的上下文逻辑、公告管理等模块进行了系统性代码自查,清理了部分硬编码,代码规范性得到提升 | +| 4 | **个人技能考试应对** | **完成** | 合理安排时间,在12月24日顺利完成个人技能考试,期间项目管理维持稳定,考试后立即恢复项目冲刺节奏 | +| 5 | **多数据库适配方案评估(统筹)** | **完成** | 密切跟踪梁峻耀负责的SQLite和PostgreSQL适配进度,技术方案在周三前成功攻克,为后续测试奠定基础 | +| 6 | **前端 UI 适配与弹窗优化跟进** | **完成** | 协调跟进李文韬负责的前端布局适配与自定义UI弹窗开发,确保与后端异常信息的对接效果良好 | + +## 对团队工作的建议 + +1. **技术方案持续优化**:建议对RAG检索机制进行更全面的性能测试,特别是在高并发场景下的稳定性和响应时间; +2. **代码审查机制完善**:建议建立定期的团队代码交叉审查机制,共同提升代码质量和规范性; +3. **文档同步更新**:技术方案升级后,应及时更新相关技术文档和API文档,确保文档与实际实现一致; +4. **经验总结分享**:建议将本周攻克多数据库适配和实现RAG机制的技术经验进行总结分享,形成团队知识积累。 + +## 小结 + +1. **技术突破实现**:成功实现从简单上下文拼接向RAG检索机制的技术升级,这是本周最重要的技术成果; +2. **项目管理有效**:在个人技能考试期间仍能有效统筹项目进度,确保团队平稳过渡到Beta版本优化阶段; +3. **质量意识提升**:通过代码规范性自查,个人编码质量和规范意识得到进一步强化; +4. **时间管理成功**:在考试和项目双重压力下合理安排时间,实现个人学业与项目管理的平衡; +5. **团队协作顺畅**:与梁峻耀、李文韬等团队成员协作良好,确保各项任务协调推进; +6. **待优化工作**:RAG机制的性能测试和优化需要在下周重点推进,确保系统在真实业务场景下的稳定性。 + +--- + +## 【注】 + +1. **技术成果显著**:RAG检索机制的实现是本项目智能化水平的重要突破,建议在下周进行专项性能测试; +2. **管理效果良好**:在考试压力下仍能保持项目管理稳定性,展现了良好的时间管理和应急协调能力; +3. **后续工作重点**:需继续推进代码规范性工作,同时为项目最终评审做好充分准备; diff --git a/doc/process/weekly/week-14/members/yimuran-weekly-plan-14.md b/doc/process/weekly/week-14/members/yimuran-weekly-plan-14.md new file mode 100644 index 0000000..aba2268 --- /dev/null +++ b/doc/process/weekly/week-14/members/yimuran-weekly-plan-14.md @@ -0,0 +1,35 @@ +# 个人周计划-第14周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-22 + +**结束时间:** 2025-12-28 + +## 本周核心目标 + +1. **管理员功能增强**:完成管理员模块的深度开发,包括账号安全加密、用户封禁逻辑及模型配置管理,提升系统后台的管控能力。 +3. **技能考试通关**:合理安排时间复习,确保周三的个人技能考试顺利通过。 +4. **团队协作记录**:履行本周会议记录员职责,整理并归档第14周的小组会议纪要。 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人/协作** | **情况说明** | +| -------- | ------------------------ | ----------------- | ------------------------------------------------------------ | +| 1 | **管理员功能开发与增强** | 个人 | **【本周重点】** 1. 实现用户账号的加密存储与验证逻辑;2. 开发用户“封禁/解封”判断逻辑(中间件或装饰器实现);3. 建立并实现模型配置表(Key/Value)的增删改查接口,用于动态管理AI参数。 | +| 2 | **个人技能考试应对** | 个人 | **【周三考试】** 12月24日(周三)参加技能考试。周一至周二需预留部分时间进行针对性复习,确保不影响项目进度的前提下顺利通过考核。 | +| 3 | **代码规范审计与提分** | 个人 | 严格对照 Google Python 编码规范,对自己编写的“用户管理”和“管理员”模块代码进行自查。 | +| 4 | **双重校验逻辑对接** | 个人 (与前端协作) | 与前端(李文韬、李果霖)沟通管理员操作的校验逻辑,确保前端的预校验规则与后端数据库约束保持一致,防止非法数据录入。 | +| 5 | **小组会议纪要整理** | 个人 | **【轮值职责】** 负责记录本周小组会议的核心讨论点、决议事项及下周分工,并在会议结束后24小时内整理成Markdown文档归档。 | + +## 小结 + +1. **功能攻坚**:本周管理员模块将从“能用”升级为“好用且安全”。模型配置表的开发将为系统提供动态调整AI参数的能力,是后台管理的核心功能。 + +2. **考试与开发平衡**:本周面临考试压力,我将利用碎片时间复习,同时保证周四至周五集中精力完成代码开发和规范优化。 + + \ No newline at end of file diff --git a/doc/process/weekly/week-14/members/yimuran-weekly-summary-14.md b/doc/process/weekly/week-14/members/yimuran-weekly-summary-14.md new file mode 100644 index 0000000..ed3a482 --- /dev/null +++ b/doc/process/weekly/week-14/members/yimuran-weekly-summary-14.md @@ -0,0 +1,33 @@ +# 个人周总结-第14周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-22 + +**结束时间:** 2025-12-28 + +## 本周任务完成情况 + +| **序号** | **计划内容** | **完成情况** | **情况说明** | +| -------- | ------------------------ | ------------ | ------------------------------------------------------------ | +| 1 | **管理员功能开发与增强** | **完成** | **【核心产出】** 成功实现了用户账号的加密存储与验证逻辑,开发了基于中间件的用户封禁/解封判断机制,并完成了模型配置表(Key/Value)的增删改查接口,为系统提供了动态管理AI参数的能力。 | +| 2 | **个人技能考试应对** | **完成** | 合理分配了复习与开发时间,于周三(12月24日)顺利完成了技能考试,未影响项目核心功能的交付进度。 | +| 3 | **代码规范审计与提分** | **进行中** | 严格对照 Google Python 编码规范,对自己编写的“用户管理”和“管理员”模块代码进行了深度自查,清理了硬编码并补充了文档注释,目前正持续优化以确保护平时分。 | +| 4 | **双重校验逻辑对接** | **完成** | 与前端(李文韬、李果霖)保持了密切沟通,确保了管理员操作的前端预校验规则与后端数据库约束的一致性,有效防止了非法数据的录入。 | +| 5 | **小组会议纪要整理** | **完成** | **【轮值职责】** 负责记录并整理了本周会议关于RAG技术落地、Celery异步调度及云端部署策略的核心讨论内容,并按时归档。 | +| 6 | **周总结与文档归档** | **完成** | 整理了本周管理员模块的代码产出,撰写了个人周总结,并协助汇总了小组的周报数据。 | + +## 对团队工作的建议 + +1. **测试用例覆盖**:管理员模块(尤其是封禁和配置修改)权限较高,建议在下周的集成测试中重点编写边缘测试用例,防止误操作导致系统不可用。 +2. **文档同步**:随着模型配置表的上线,建议尽快更新《用户手册》中的管理员操作指南,说明各项AI参数的具体含义。 + +## 小结 + +1. **后台管控能力升级**:本周重点攻克了管理员的高级功能,通过账号加密和动态配置表,显著提升了系统的安全性和可维护性,使后台管理从“能用”迈向了“好用”。 +2. **抗压能力体现**:在面临技能考试和项目Beta版优化的双重压力下,通过高效的时间管理,既保证了考试顺利通过,又按时交付了高质量的代码。 +3. **规范意识深化**:将代码规范性检查融入日常开发流程,不仅是为了应付考核,更是为了提升代码的可读性和团队协作效率。 \ No newline at end of file diff --git a/doc/process/weekly/week-15/group/weekly-plan-15.md b/doc/process/weekly/week-15/group/weekly-plan-15.md new file mode 100644 index 0000000..29f2e8a --- /dev/null +++ b/doc/process/weekly/week-15/group/weekly-plan-15.md @@ -0,0 +1,40 @@ +# 小组周计划-第15周 + +## 团队名称和起止时间 + +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-12-29 +**结束时间:** 2026-01-04 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人** | **情况说明** | +| --- | --- | --- | --- | +| 1 | **完善风控模块** | 伊木然 | 系统自动判断风险用户,由管理员负责手动封禁 | +| 2 | **全系统集成测试** | 全体成员 | 针对目前已完成的所有模块进行集成测试,确保业务完善稳定 | +| 3 | **修复前端BUG** | 前端人员 | 解决界面布局适配遗留问题及测试中发现的 UI 交互缺陷,修复可用性问题 | +| 4 | **修复后端BUG** | 后端人员 | 修复测试过程中发现的业务逻辑漏洞 | +| 5 | **部署云服务器** | 李文韬 | 云端环境配置,系统部署,确保系统在云端稳定运行 | +| 6 | **用户手册编写** | 李果霖,伊木然 | 整理系统功能说明,站在用户视角编写操作指南,包含功能说明与常见问题处理,为用户提供清晰的操作指引与配置参考 | +| 7 | **测试报告编写** | 梁峻耀,李文韬,王利蓉 | 编写测试用例,汇总集成测试结果 | + + + +## 小结 + +**系统稳定**:将前端、后端以及新完成的风控模块进行集成测试,确保各功能模块在联动时业务逻辑严密、无冲突。 + +**由本地转向云端**:云服务器部署是本周重点,系统将从本地开发环境正式迁移至真实的云运行环境。 + +**同步软硬件交付物**:在修复 BUG 的同时,开始测试报告和用户手册的编写,确保软件代码与说明文档同步产出。 + +**解决历史遗留问题**:前端人员集中处理之前版本中存在的组件变形、布局错乱等响应式适配问题,提升系统的视觉稳定性。 + + + +## 【注】 + +**测试反馈时效性**:集成测试过程中发现的任何 BUG 需即时录入在线文档,开发人员尽快修复。 + +**文档颗粒度**:用户手册编写需涵盖“常见问题处理”,特别是针对非技术背景的用户,确保操作指南清晰易懂。 + diff --git a/doc/process/weekly/week-15/members/liangjunyao-weekly-plan-15.md b/doc/process/weekly/week-15/members/liangjunyao-weekly-plan-15.md new file mode 100644 index 0000000..fc7535d --- /dev/null +++ b/doc/process/weekly/week-15/members/liangjunyao-weekly-plan-15.md @@ -0,0 +1,27 @@ +# 个人周计划 - 第15周 + +**姓  名**:梁峻耀 +**团队名称**:3班-葫芦娃救bug +**开始时间**:2025-12-29 +**结束时间**:2026-01-04 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 执行人 | 情况说明 | +| ---- | -------------- | -------------- | ------------------------------------------------------------ | +| 1 | 测试报告编写 | 李文韬、王利蓉 | 编写风控模块、AI队列等核心功能的测试用例(覆盖12+核心接口);汇总集成测试结果,形成结构化报告;与前端团队同步测试问题,推动BUG闭环率≥90% | +| 3 | 后端BUG修复 | 全体成员 | 重点修复集成测试中发现的问题 | +| 4 | 全系统集成测试 | 全体成员 | 针对目前已完成的所有模块进行集成测试,确保业务完善稳定 | + +## 小结 + +**核心目标**: + +- 完成系统集成测试报告,为上线提供质量保障 +- 高效修复后端BUG,确保核心功能稳定运行 + +**技术规划**: + +- 采用"问题分类-优先级排序-协同修复"模式处理BUG,提高修复效率 +- 通过双数据库验证确保修复方案的兼容性,避免引入新问题 +- 测试报告将包含性能对比数据(本地vs云端),建立质量评估基准 diff --git a/doc/process/weekly/week-15/members/liguolin-weekly-plan-15.md b/doc/process/weekly/week-15/members/liguolin-weekly-plan-15.md new file mode 100644 index 0000000..f6e0a1a --- /dev/null +++ b/doc/process/weekly/week-15/members/liguolin-weekly-plan-15.md @@ -0,0 +1,27 @@ +# 个人周计划-第15周 + +## 姓名与起止时间 + +**姓  名:** 李果霖 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-29 + +**结束时间:** 2026-01-04 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | -------------------- | ---------- | ------------------------------------------------------------ | +| 1 | **用户手册编写** | 伊木然 | **【核心交付物】** 负责编写《用户手册》中关于**核心业务功能**的章节(AI对话操作、RAG检索使用、ER图与报表导出等),并确保技术描述的准确性,做到图文并茂。 | +| 2 | **云端部署技术支持** | 李文韬 | 协助完成云服务器的**Celery异步任务队列**及**多数据库环境**(PostgreSQL/SQLite)的配置与调试,确保本地的高级特性在云端能正常运行。 | +| 3 | **全系统集成测试** | 全体成员 | 参与全链路测试,重点验证自己负责的**RAG检索准确性**、**ER图生成**及**异步任务调度**在云端环境下的表现,确保无阻塞与崩溃。 | +| 4 | **后端BUG修复** | 后端人员 | **【响应式任务】** 针对集成测试中发现的与异步任务、数据库适配相关的后端逻辑漏洞进行即时修复,协助团队达成Beta版本零严重Bug的目标。 | +| 5 | **代码规范最终审查** | 个人 | 在项目封版前,对自己负责模块的代码进行最后一次规范性审查,清理无用注释与调试代码,确保考核不丢分。 | + +## 小结 + +1. **文档交付**:本周工作重心从代码开发转向**文档交付**,重点是输出高质量的《用户手册》,确保用户能够理解并使用我们开发的RAG与可视化功能。 +2. **云端落地**:作为Celery和多库适配的开发者,必须确保这些依赖环境在云服务器上**正确配置**,协助李文韬完成从本地到云端的“惊险一跃”。 +3. **质量收口**:配合全员进行集成测试,对负责模块的稳定性负责,并做好本周的会议记录,站好Beta版本开发的最后一班岗。 \ No newline at end of file diff --git a/doc/process/weekly/week-15/members/liwentao-weekly-plan-15.md b/doc/process/weekly/week-15/members/liwentao-weekly-plan-15.md new file mode 100644 index 0000000..af6225b --- /dev/null +++ b/doc/process/weekly/week-15/members/liwentao-weekly-plan-15.md @@ -0,0 +1,26 @@ +# 个人周计划-第15周 + +## 姓名与起止时间 + +**姓 名**:李文韬 + +**团队名称**:3班-葫芦娃救bug + +**开始时间**:2025-12-29 + +**结束时间**:2026-01-04 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **协作人** | **情况说明** | +| -------- | ---------------------- | -------------- | ------------------------------------------------------------ | +| 1 | **部署云服务器** | 个人 | 配置云端运行环境,完成系统的正式部署,实现从本地开发环境到云端环境的迁移,确保系统在线稳定运行。 | +| 2 | **修复前端BUG** | 前端开发人员 | 重点解决历史遗留的界面布局适配问题(如组件变形、错乱),并修复集成测试中发现的UI交互缺陷。 | +| 3 | **集成测试与报告编写** | 梁峻耀,王利蓉 | 参与全系统集成测试,协同编写测试报告,汇总测试用例与结果,确保软件交付质量。 | +| 4 | **全系统集成测试** | 全体成员 | 配合后端与风控模块进行联调,验证各模块间业务逻辑的严密性与稳定性。 | + +## 小结 + +1. **生产环境交付:** 本周工作的重中之重是**云服务器部署**。作为技术实现的关键一环,需要确保系统能够脱离本地环境,在真实的云端网络中稳定提供服务。 +2. **前端收尾与调优:** 针对之前版本存在的响应式适配问题(如浏览器缩放导致的组件变形),本周将进行集中修复(任务2),确保在交付前消除明显的视觉与交互缺陷。 +3. **测试文档同步:** 在开发工作收尾的同时,将重心转向测试与文档工作。通过与团队成员协作完成集成测试报告,确保“代码、环境、文档”三位一体同步交付。 \ No newline at end of file diff --git a/doc/process/weekly/week-15/members/wanglirong-weekly-plan-15.md b/doc/process/weekly/week-15/members/wanglirong-weekly-plan-15.md new file mode 100644 index 0000000..8230b81 --- /dev/null +++ b/doc/process/weekly/week-15/members/wanglirong-weekly-plan-15.md @@ -0,0 +1,38 @@ +# 个人周计划-第15周 + +## 姓名和起止时间 + +**姓  名:** 王利蓉 +**团队名称:** 3班-葫芦娃救bug +**开始时间:** 2025-12-29 +**结束时间:** 2026-01-04 + +## 本周任务计划安排 + +| 序号 | 计划内容 | 协作人 | 情况说明 | +|------|----------|--------|----------| +| 1 | **全系统集成测试参与** | 全体成员 | 参与针对目前已完成的所有模块进行集成测试,重点测试负责的RAG检索、会话管理、报表和公告功能 | +| 2 | **测试报告编写(核心模块)** | 梁峻耀、李文韬 | **【本周重点】** 负责编写RAG检索、会话管理、报表、公告等核心模块的测试用例,汇总集成测试结果 | +| 3 | **后端BUG修复支持** | 后端人员 | 协助修复测试过程中发现的与负责模块相关的业务逻辑漏洞,特别是RAG检索的性能和准确性问题 | +| 4 | **风控模块测试支持** | 伊木然 | 配合测试风控模块与现有功能的集成,确保风险用户判断不影响正常业务流程 | +| 5 | **用户手册技术内容审核** | 李果霖、伊木然 | 审核用户手册中关于RAG检索、报表功能等复杂功能的技术描述准确性 | +| 6 | **云端部署验证测试** | 李文韬 | 在云服务器部署完成后,验证负责的功能模块在云端环境的运行稳定性 | +| 7 | **代码规范性最终检查** | 个人 | 对负责模块代码进行最终规范性检查,清理遗留问题,为项目评审做好准备 | + +## 小结 + +1. **测试质量保障:** 本周重点参与全系统集成测试,确保负责的核心功能模块稳定可靠; +2. **文档工作负责:** 认真编写测试报告,为项目交付提供完整的技术质量记录; +3. **问题修复支持:** 积极配合修复测试中发现的问题,提升系统整体质量; +4. **云端环境验证:** 关注云服务器部署进展,确保功能在真实环境下的稳定运行; +5. **代码质量把关:** 进行代码规范性最终检查,确保符合评审要求; +6. **希望获得的帮助:** 希望在系统性能测试和云端环境调试方面获得更多指导。 + +--- + +## 【注】 + +1. **测试责任明确:** 需重点确保RAG检索、会话管理等复杂功能的测试覆盖率和质量; +2. **文档编写要求:** 测试报告需详细记录测试用例、执行结果和问题分析; +3. **问题响应时效:** 测试中发现的问题需及时记录并协助修复; +4. **时间节点把控:** 云服务器部署完成后需及时进行验证测试,确保功能完整可用。 \ No newline at end of file diff --git a/doc/process/weekly/week-15/members/yimuran-weekly-plan-15.md b/doc/process/weekly/week-15/members/yimuran-weekly-plan-15.md new file mode 100644 index 0000000..edcad0b --- /dev/null +++ b/doc/process/weekly/week-15/members/yimuran-weekly-plan-15.md @@ -0,0 +1,34 @@ +# 个人周计划-第15周 + +## 姓名与起止时间 + +**姓  名:** 伊木然 + +**团队名称:** 3班-葫芦娃救bug + +**开始时间:** 2025-12-29 + +**结束时间:** 2026-01-04 + +## 本周核心目标 + +1. **风控模块闭环**:完成“风险用户自动判定”功能的逻辑开发,并与管理员的封禁功能打通,实现系统自动预警。 +2. **文档交付**:与李果霖协作完成《用户手册》的编写,确保非技术用户也能顺畅使用系统。 +3. **系统稳定性保障**:参与全系统集成测试,及时修复测试中发现的后端业务逻辑Bug,保障云端部署顺利。 + +## 本周任务计划安排 + +| **序号** | **计划内容** | **执行人/协作** | **情况说明** | +| -------- | -------------------- | ------------------- | ------------------------------------------------------------ | +| 1 | **完善风控模块逻辑** | 个人 | **【本周重点】** 实现系统自动判断风险用户的后端逻辑(如:监测短时间内连续登录失败、API调用频率异常等),并将异常状态标记写入数据库,供管理员审核或自动限制。 | +| 2 | **用户手册编写** | 个人 (与李果霖协作) | **【交付物】** 负责编写《用户手册》中关于“账号注册/登录”、“个人设置”及“管理员后台”的操作指南,并整理“常见问题处理(FAQ)”章节。 | +| 3 | **全系统集成测试** | 个人 (全体成员) | 在本地及云端环境下,对已完成的所有模块(含新上线的风控、RAG、Celery调度)进行全链路测试,重点验证业务流程的连贯性。 | +| 4 | **后端Bug修复** | 个人 (后端人员) | **【响应式任务】** 针对集成测试报告中指出的后端逻辑漏洞(特别是风控误判、数据一致性问题)进行紧急修复,确保Beta版本零严重Bug。 | +| 5 | **配合云端部署** | 个人 (与李文韬协作) | 协助李文韬进行云服务器的环境配置,解决后端代码在Linux云环境下的依赖兼容性问题(如数据库连接、Python库版本)。 | +| 6 | **周总结与项目复盘** | 个人 | 2025-01-04,整理本周的风控代码产出和用户手册文档,撰写个人周总结,并协助汇总小组的最终交付情况。 | + +## 小结 + +1. **功能收官**:本周将完成Beta版本的最后一块拼图——**风控自动判定**,使系统的安全机制形成闭环。 +2. **软硬同步**:在保证代码质量的同时,将投入大量精力在《用户手册》的编写上,确保交付给用户的不仅是软件,还有清晰易懂的使用说明。 +3. **质量底线**:作为后端开发,本周的底线是“快速响应Bug”,全力配合测试和部署工作,确保系统在云端稳定运行。 \ No newline at end of file diff --git a/doc/project/01-需求文档/前景与范围文档第一稿.docx b/doc/project/01-需求文档/前景与范围文档第一稿.docx new file mode 100644 index 0000000..c88b8e4 Binary files /dev/null and b/doc/project/01-需求文档/前景与范围文档第一稿.docx differ diff --git a/doc/project/01-需求文档/用例文档最终稿.docx b/doc/project/01-需求文档/用例文档最终稿.docx new file mode 100644 index 0000000..b03bdc0 Binary files /dev/null and b/doc/project/01-需求文档/用例文档最终稿.docx differ diff --git a/doc/project/01-需求文档/用例文档第一稿.docx b/doc/project/01-需求文档/用例文档第一稿.docx new file mode 100644 index 0000000..35ccb3c Binary files /dev/null and b/doc/project/01-需求文档/用例文档第一稿.docx differ diff --git a/doc/project/01-需求文档/需求规格说明书最终稿.docx b/doc/project/01-需求文档/需求规格说明书最终稿.docx new file mode 100644 index 0000000..a4e6122 Binary files /dev/null and b/doc/project/01-需求文档/需求规格说明书最终稿.docx differ diff --git a/doc/project/01-需求文档/需求规格说明书第一稿.docx b/doc/project/01-需求文档/需求规格说明书第一稿.docx new file mode 100644 index 0000000..c22da4a Binary files /dev/null and b/doc/project/01-需求文档/需求规格说明书第一稿.docx differ diff --git a/doc/project/02-设计文档/ UML-活动图-顺序图-类图.pdf b/doc/project/02-设计文档/ UML-活动图-顺序图-类图.pdf new file mode 100644 index 0000000..bb73b1a Binary files /dev/null and b/doc/project/02-设计文档/ UML-活动图-顺序图-类图.pdf differ diff --git a/doc/project/02-设计文档/README.md b/doc/project/02-设计文档/README.md new file mode 100644 index 0000000..52d6e1c --- /dev/null +++ b/doc/project/02-设计文档/README.md @@ -0,0 +1,11 @@ +# 02-设计文档索引 + +## 文档列表 + +| 文档名称 | 版本 | 最后更新 | 负责人 | 状态 | +|---|---|---|---|---| +| [数据库设计文档](./数据库设计文档.docx) | v1.0 | 2025-XX-XX | 待定 | 待完成 | +| [UML设计文档](./UML-活动图-顺序图-类图.pdf) | v1.0 | 2025-XX-XX | 待定 | 待完成 | + +**修改历史** +- 2025-XX-XX: 初始化设计文档目录结构 diff --git a/doc/project/02-设计文档/redis缓存设计文档.md b/doc/project/02-设计文档/redis缓存设计文档.md new file mode 100644 index 0000000..a209ffe --- /dev/null +++ b/doc/project/02-设计文档/redis缓存设计文档.md @@ -0,0 +1,252 @@ +# Redis 缓存设计文档 + +## 1. 概述 + +本文档描述了项目中公告功能的 Redis 缓存实现方案。通过引入 Redis 缓存,可以显著提升公告查询的性能,减少数据库访问压力。 + +## 2. 缓存策略 + +### 2.1 缓存范围 + +- **公告列表缓存**:只缓存已发布(published)状态的公告列表 +- **公告详情缓存**:只缓存已发布(published)状态的公告详情 +- **未发布、草稿、已下架等状态的公告不缓存** + +### 2.2 缓存键设计 + +#### 2.2.1 公告列表缓存键 + +``` +announcement:list:published:page_{page}:size_{page_size} +``` + +示例: +- `announcement:list:published:page_1:size_10` +- `announcement:list:published:page_2:size_20` + +#### 2.2.2 公告详情缓存键 + +``` +announcement:detail:{announcement_id} +``` + +示例: +- `announcement:detail:1` +- `announcement:detail:100` + +### 2.3 缓存过期时间 + +- **过期时间**:3600 秒(1 小时) +- **过期策略**:自动过期 + 主动清除 + +## 3. 实现架构 + +### 3.1 组件层次 + +``` +API 层 (announcement.py) + ↓ +CRUD 层 (crud_announcement.py) + ↓ +缓存服务层 (announcement_cache_service.py) + ↓ +Redis 连接层 (redis.py) +``` + +### 3.2 数据流程 + +#### 3.2.1 查询流程 + +1. **列表查询**: + - 用户请求公告列表 + - CRUD 层检查是否为已发布状态 + - 尝试从 Redis 获取缓存 + - 缓存命中:返回缓存数据 + - 缓存未命中:查询数据库 → 转换数据格式 → 存入缓存 → 返回数据 + +2. **详情查询**: + - 用户请求公告详情 + - CRUD 层检查公告状态 + - 尝试从 Redis 获取缓存 + - 缓存命中:返回缓存数据 + - 缓存未命中:查询数据库 → 转换数据格式 → 存入缓存 → 返回数据 + +#### 3.2.2 更新流程 + +1. **创建公告**: + - 如果状态为已发布,创建后清除列表缓存 + - 如果状态为草稿,不操作缓存 + +2. **更新公告**: + - 更新数据库 + - 清除列表缓存 + - 清除详情缓存 + +3. **删除公告**: + - 删除数据库记录 + - 清除列表缓存 + - 清除详情缓存 + +## 4. 核心实现 + +### 4.1 缓存服务类 + +`AnnouncementCacheService` 提供以下核心方法: + +#### 4.1.1 列表缓存方法 + +- `get_list_from_cache(page, page_size)`:从缓存获取列表 +- `set_list_to_cache(page, page_size, total, items)`:设置列表缓存 +- `invalidate_list_cache()`:清除列表缓存 + +#### 4.1.2 详情缓存方法 + +- `get_detail_from_cache(announcement_id)`:从缓存获取详情 +- `set_detail_to_cache(announcement_id, announcement_data)`:设置详情缓存 +- `invalidate_detail_cache(announcement_id)`:清除详情缓存 + +#### 4.1.3 辅助方法 + +- `invalidate_all_announcement_cache()`:清除所有公告缓存 +- `get_fresh_data_from_db(db, page, page_size)`:从数据库获取最新数据 + +### 4.2 CRUD 层集成 + +在 `crud_announcement.py` 中: + +1. **查询时**:优先从缓存获取,未命中则查询数据库并缓存 +2. **创建时**:如果是已发布状态,清除列表缓存 +3. **更新时**:清除列表和详情缓存 +4. **删除时**:清除列表和详情缓存 + +## 5. 性能优化 + +### 5.1 缓存命中率 + +- 只缓存已发布状态的公告,避免无效缓存 +- 合理的过期时间(1小时),平衡数据一致性和性能 +- 分页缓存,避免大列表一次性加载 + +### 5.2 内存使用 + +- 使用 SCAN 命令清除缓存,避免 KEYS 命令阻塞 +- JSON 序列化存储,压缩数据大小 +- 合理的过期时间,自动清理过期数据 + +### 5.3 并发安全 + +- Redis 本身是单线程,保证操作原子性 +- 使用 `setex` 命令保证设置和过期时间的原子性 +- 数据库操作和缓存操作分离,避免事务冲突 + +## 6. 监控和维护 + +### 6.1 日志记录 + +所有缓存操作都有日志记录: + +- 缓存命中:`Cache hit for announcement list: {key}` +- 缓存设置:`Cache set for announcement list: {key}` +- 缓存清除:`Deleted announcement list cache keys: {keys}` +- 错误日志:`Error getting announcement list from cache: {error}` + +### 6.2 Redis 连接管理 + +- 使用 `get_redis()` 获取连接 +- 连接不可用时自动降级到数据库查询 +- 服务启动时初始化 Redis 连接 + +## 7. 扩展性 + +### 7.1 其他功能的缓存 + +此缓存架构可以轻松扩展到其他功能: + +1. **用户信息缓存**:用户资料、权限等 +2. **项目信息缓存**:项目列表、项目详情等 +3. **知识库缓存**:知识文档、FAQ 等 + +### 7.2 缓存策略调整 + +可以根据实际需求调整: + +1. **过期时间**:根据数据更新频率调整 +2. **缓存粒度**:可以按用户、角色等维度缓存 +3. **缓存层级**:可以添加多级缓存(本地缓存 + Redis) + +## 8. 注意事项 + +### 8.1 数据一致性 + +- 缓存是最终一致的,不是强一致 +- 允许 1 小时内的数据延迟 +- 重要操作后主动清除缓存 + +### 8.2 Redis 可用性 + +- Redis 不可用时,系统自动降级到数据库查询 +- 不会因为 Redis 故障导致系统不可用 +- 建议配置 Redis 高可用(主从、哨兵或集群) + +### 8.3 内存管理 + +- 定期监控 Redis 内存使用情况 +- 根据实际情况调整过期时间 +- 必要时可以清理过期缓存 + +## 9. 测试 + +### 9.1 单元测试 + +提供了测试脚本 `test_announcement_cache.py`,测试: + +- 缓存设置和获取 +- 缓存清除功能 +- Redis 连接管理 + +### 9.2 集成测试 + +建议测试场景: + +1. **正常流程**:查询 → 缓存命中 → 更新 → 缓存清除 → 重新查询 +2. **并发场景**:多个用户同时查询和更新 +3. **异常场景**:Redis 不可用、网络异常等 + +## 10. 部署建议 + +### 10.1 Redis 配置 + +```yaml +# redis_client.conf 建议配置 +maxmemory 256mb +maxmemory-policy allkeys-lru +save 900 1 +save 300 10 +save 60 10000 +``` + +### 10.2 监控指标 + +建议监控以下指标: + +- 缓存命中率 +- Redis 内存使用率 +- Redis 连接数 +- 缓存操作延迟 + +### 10.3 备份策略 + +- 定期备份 Redis 数据 +- 配置持久化(RDB + AOF) +- 考虑跨机房备份 + +## 11. 总结 + +通过引入 Redis 缓存,公告功能的性能得到了显著提升: + +1. **性能提升**:减少数据库查询,提升响应速度 +2. **扩展性**:架构清晰,易于扩展到其他功能 +3. **可靠性**:Redis 不可用时自动降级,不影响系统运行 +4. **可维护性**:代码结构清晰,日志完善,易于维护 + +此缓存方案为项目的性能优化奠定了良好基础,后续可以逐步扩展到其他高频查询的功能模块。 diff --git a/doc/project/02-设计文档/数据库设计文档.docx b/doc/project/02-设计文档/数据库设计文档.docx new file mode 100644 index 0000000..e170cba Binary files /dev/null and b/doc/project/02-设计文档/数据库设计文档.docx differ diff --git a/doc/project/03-计划文档/README.md b/doc/project/03-计划文档/README.md new file mode 100644 index 0000000..1b0dc4c --- /dev/null +++ b/doc/project/03-计划文档/README.md @@ -0,0 +1,12 @@ +# 03-计划文档索引 + +## 文档列表 + +| 文档名称 | 版本 | 最后更新 | 负责人 | 状态 | +|---|---|---|---|---| +| [迭代开发计划第一稿](./迭代开发计划第一稿.docx) | v1.0 | 2025-XX-XX | 待定 | 待完成 | +| [迭代开发计划第二稿](./迭代开发计划第二稿.docx) | v2.0 | 2025-XX-XX | 待定 | 待完成 | +| [迭代开发计划第三稿](./迭代开发计划第三稿.docx) | v3.0 | 2025-XX-XX | 待定 | 待完成 | + +**修改历史** +- 2025-XX-XX: 初始化计划文档目录结构 diff --git a/doc/project/03-计划文档/迭代开发计划第一稿.docx b/doc/project/03-计划文档/迭代开发计划第一稿.docx new file mode 100644 index 0000000..bf75cad Binary files /dev/null and b/doc/project/03-计划文档/迭代开发计划第一稿.docx differ diff --git a/doc/project/03-计划文档/迭代开发计划第三稿.docx b/doc/project/03-计划文档/迭代开发计划第三稿.docx new file mode 100644 index 0000000..23fa30f Binary files /dev/null and b/doc/project/03-计划文档/迭代开发计划第三稿.docx differ diff --git a/doc/project/03-计划文档/迭代开发计划第二稿.docx b/doc/project/03-计划文档/迭代开发计划第二稿.docx new file mode 100644 index 0000000..86f5150 Binary files /dev/null and b/doc/project/03-计划文档/迭代开发计划第二稿.docx differ diff --git a/doc/project/04-用户手册/README.md b/doc/project/04-用户手册/README.md new file mode 100644 index 0000000..fa66773 --- /dev/null +++ b/doc/project/04-用户手册/README.md @@ -0,0 +1,10 @@ +# 04-用户手册索引 + +## 文档列表 + +| 文档名称 | 版本 | 最后更新 | 负责人 | 状态 | +|---|---|---|---|---| +| [用户手册](./用户手册.docx) | v1.0 | 2025-XX-XX | 待定 | 待完成 | + +**修改历史** +- 2025-XX-XX: 初始化用户手册目录结构 diff --git a/doc/project/05-测试报告/README.md b/doc/project/05-测试报告/README.md new file mode 100644 index 0000000..5a48f07 --- /dev/null +++ b/doc/project/05-测试报告/README.md @@ -0,0 +1,10 @@ +# 05-测试报告索引 + +## 文档列表 + +| 文档名称 | 版本 | 最后更新 | 负责人 | 状态 | +|---|---|---|---|---| +| [测试报告](./测试报告.docx) | v1.0 | 2025-XX-XX | 待定 | 待完成 | + +**修改历史** +- 2025-XX-XX: 初始化测试报告目录结构 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c96f70 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,221 @@ +version: '3.8' + +services: + # ----------------------------------------------------------------------------- + # 1. 基础设施服务 (数据库 & 缓存) + # ----------------------------------------------------------------------------- + + # PostgreSQL (后端业务数据库) + postgres-meta: + image: postgres:15 + container_name: meta_postgres + restart: always + ports: + - "5432:5432" + environment: + POSTGRES_DB: auto_db_deployment + POSTGRES_USER: admin + POSTGRES_PASSWORD: secure_2025 + command: > + postgres -c listen_addresses='*' -c max_connections=100 + volumes: + - postgres_data:/var/lib/postgresql/data + # 如果你有初始化脚本,请取消下面的注释 + # - ./init-db/postgres-init.sh:/docker-entrypoint-initdb.d/init.sh:ro + networks: + - app-network + + # MySQL (用户生成的数据库实例资源池) + mysql-user: + image: mysql:8.0 + container_name: user_mysql + restart: always + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: root_secure_2025 + TZ: Asia/Shanghai + command: --skip-name-resolve=OFF + volumes: + - mysql_data:/var/lib/mysql + networks: + - app-network + + postgres-user: + image: postgres:15 + container_name: user_postgres + restart: always + ports: + - "2345:5432" + environment: + TZ: Asia/Shanghai + POSTGRES_PASSWORD: root_secure_2025 + volumes: + - postgres_user_data:/var/lib/postgresql/data + networks: + - app-network + + + # Redis (缓存 & 消息队列) + redis: + image: redis:7-alpine + container_name: redis + restart: always + ports: + - "6379:6379" + # 注意:你的 backend/app/celery_app.py 中硬编码了 redis://redis:6379/0 (无密码) + # 为了匹配代码,这里暂时去掉 --requirepass,或者你需要修改代码中的 broker URL + command: redis-server --bind 0.0.0.0 --appendonly yes --notify-keyspace-events Ex + volumes: + - redis_data:/data + networks: + - app-network + + # ChromaDB (向量数据库,用于 RAG 检索) + chromadb: + image: chromadb/chroma:0.5.23 + container_name: chromadb + restart: always + ports: + - "8100:8000" + environment: + - IS_PERSISTENT=TRUE + - PERSIST_DIRECTORY=/chroma/chroma + - ANONYMIZED_TELEMETRY=FALSE + volumes: + - chroma_data:/chroma/chroma + networks: + - app-network + + # ----------------------------------------------------------------------------- + # 2. 应用服务 (后端 & 任务队列 & 前端) + # ----------------------------------------------------------------------------- + + # 后端 API 服务 (FastAPI) + backend: + build: + context: ./src/backend + dockerfile: Dockerfile.dev + container_name: backend + ports: + - "8000:8000" + volumes: + - ./src/backend:/app + environment: + # Fix import errors by adding inner app directory to PYTHONPATH + - PYTHONPATH=/app/app + # --- Pydantic BaseSettings 覆盖配置 (使用双下划线映射嵌套配置) --- + # 覆盖 config.yaml 中的 db 部分 + - DB__HOST=postgres-meta + - DB__PORT=5432 + - DB__USERNAME=admin + - DB__PASSWORD=secure_2025 + - DB__DATABASE=auto_db_deployment + + # 覆盖 config.yaml 中的 redis_client 部分 + - REDIS__HOST=redis + - REDIS__PORT=6379 + + # ChromaDB 配置 + - CHROMA__HOST=chromadb + - CHROMA__PORT=8000 + + # 覆盖 config.yaml 中的 app 部分 + - APP__HOST=0.0.0.0 + + # MySQL 用户数据库配置 (Docker 内部网络) + - MYSQL__HOST=mysql-user + - MYSQL__PORT=3306 + + # PostgreSQL 用户数据库配置 (Docker 内部网络) + - POSTGRESQL__HOST=postgres-user + - POSTGRESQL__PORT=5432 + + # 如果代码中有逻辑读取这些非标准变量,保留它们以防万一 + - POSTGRES_SERVER=postgres-meta + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=secure_2025 + - REDIS_HOST=redis + command: uvicorn app.server:my_app --host 0.0.0.0 --port 8000 --reload --proxy-headers --forwarded-allow-ips='*' + depends_on: + - postgres-meta + - redis + - chromadb + networks: + - app-network + + # Celery Worker (异步任务处理) + celery_worker: + build: + context: ./src/backend + dockerfile: Dockerfile.dev + container_name: celery + environment: + # Fix import errors + - PYTHONPATH=/app/app + # 同样注入配置以确保 Worker 能连接到正确的 DB 和 Redis + - DB__HOST=postgres-meta + - DB__PORT=5432 + - DB__USERNAME=admin + - DB__PASSWORD=secure_2025 + - DB__DATABASE=auto_db_deployment + - REDIS__HOST=redis + - REDIS__PORT=6379 + # 显式覆盖 Celery Broker URL (以防代码没读配置) + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + # ChromaDB 配置 + - CHROMA__HOST=chromadb + - CHROMA__PORT=8000 + # MySQL 用户数据库配置 (Docker 内部网络) + - MYSQL__HOST=mysql-user + - MYSQL__PORT=3306 + # PostgreSQL 用户数据库配置 (Docker 内部网络) + - POSTGRESQL__HOST=postgres-user + - POSTGRESQL__PORT=5432 + working_dir: /app/app + # 启动命令 (添加 -Q celery 确保监听默认队列) + command: celery -A celery_app worker --loglevel=info -Q celery,default,ai_tasks + depends_on: + - redis + - backend + volumes: + - ./src/backend:/app + networks: + - app-network + + # 前端服务 (React + Vite) + frontend: + build: + context: ./src/frontend + dockerfile: Dockerfile.dev + container_name: frontend + ports: + - "3000:3000" + volumes: + - ./src/frontend:/app + - /app/node_modules + environment: + # 浏览器访问的后端地址 + - VITE_API_BASE_URL=/api + # 如果你需要传入 Gemini Key + # - GEMINI_API_KEY=${GEMINI_API_KEY} + depends_on: + - backend + networks: + - app-network + +# ----------------------------------------------------------------------------- +# 数据卷 & 网络定义 +# ----------------------------------------------------------------------------- +volumes: + postgres_data: + mysql_data: + postgres_user_data: + redis_data: + chroma_data: + + +networks: + app-network: + driver: bridge diff --git a/src/.env b/src/.env new file mode 100644 index 0000000..1f1615c --- /dev/null +++ b/src/.env @@ -0,0 +1,45 @@ +# 应用配置 +APP_NAME="Auto Database Deployment" +APP_VERSION="1.0.0" +DEBUG=True +SECRET_KEY="" + +# 业务数据库配置 +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=app_meta +POSTGRES_USER=appuser +POSTGRES_PASSWORD=meta_secure_2025 + +# Redis配置 +REDIS_URL="redis://localhost:6379/0" +REDIS_HOST="localhost" +REDIS_PORT=6379 +REDIS_DB=0 + +# Celery配置 +CELERY_BROKER_URL="redis://localhost:6379/1" +CELERY_RESULT_BACKEND="redis://localhost:6379/2" + +# JWT配置 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 +ALGORITHM="HS256" + +# AI模型配置 +AI_MODEL_API_URL="http://localhost:8001" +AI_MODEL_API_KEY="your-ai-api-key" +AI_MODEL_TIMEOUT=30 + +# 文件上传配置 +MAX_FILE_SIZE=10485760 # 10MB +UPLOAD_DIR="uploads" + +# 日志配置 +LOG_LEVEL="INFO" +LOG_FILE="logs/app.log" + +# CORS配置 +ALLOWED_ORIGINS=["http://localhost:3000", "http://127.0.0.1:3000"] +ALLOWED_METHODS=["GET", "POST", "PUT", "DELETE", "OPTIONS"] +ALLOWED_HEADERS=["*"] \ No newline at end of file diff --git a/src/.gitkeep b/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..9dbf524 --- /dev/null +++ b/src/README.md @@ -0,0 +1,11 @@ +# 源代码目录 + +这里是项目的所有源代码文件。 + +## 目录结构说明 +- `src/` - 主要源代码 +- `src/backend/` - 后端业务逻辑 +- `src/frontend/` - 前端界面代码 + +## 开发规范 +请遵循团队的代码规范和目录结构约定。 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/Dockerfile.dev b/src/backend/Dockerfile.dev new file mode 100644 index 0000000..6748e2e --- /dev/null +++ b/src/backend/Dockerfile.dev @@ -0,0 +1,12 @@ +FROM python:3.10-slim + +WORKDIR /app + +RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ + pip config set global.trusted-host mirrors.aliyun.com + +COPY requirements.txt . +RUN ls -la requirements.txt +RUN pip install --no-cache-dir -r requirements.txt || (echo "❌ 依赖安装失败!"; exit 1) + +COPY . . \ No newline at end of file diff --git a/src/backend/README.md b/src/backend/README.md new file mode 100644 index 0000000..8efb48d --- /dev/null +++ b/src/backend/README.md @@ -0,0 +1,252 @@ +# Backend 后端服务 + +基于 FastAPI + Celery 的异步后端服务,支持多数据库(PostgreSQL、MySQL、SQLite)、AI 对话、异步任务队列等功能。 + +## 技术栈 + +| 技术 | 版本 | 用途 | +|------|------|------| +| FastAPI | 0.104.1 | Web 框架 | +| SQLAlchemy | 2.0.23 | ORM | +| PostgreSQL | - | 主数据库 | +| MySQL | - | 用户项目数据库 | +| SQLite | - | 用户项目数据库 | +| Redis | 4.6.0 | 缓存 & Celery Broker | +| Celery | 5.3.4 | 异步任务队列 | +| Pydantic | 2.4.2 | 数据验证 | + +## 目录结构 + +``` +src/backend/ +├── app/ # 应用主目录 +│ ├── main.py # 启动入口 +│ ├── server.py # FastAPI 应用配置 +│ ├── celery_app.py # Celery 配置 +│ │ +│ ├── api/ # API 路由 +│ │ └── v1/ +│ │ ├── api.py # 路由汇总 +│ │ └── endpoints/ # 各模块路由 +│ │ +│ ├── config/ # 配置管理 +│ │ └── base.py # 配置基类 +│ │ +│ ├── core/ # 核心模块 +│ │ ├── config.py # 应用配置 +│ │ ├── database.py # 数据库连接 +│ │ ├── deps.py # 依赖注入 +│ │ ├── security.py # 安全模块 +│ │ ├── auth.py # 认证模块 +│ │ ├── exceptions.py # 自定义异常 +│ │ ├── exception_handlers.py # 异常处理器 +│ │ └── log.py # 日志配置 +│ │ +│ ├── crud/ # 数据访问层 +│ │ └── crud_*.py # CRUD 操作 +│ │ +│ ├── models/ # 数据模型 (SQLAlchemy) +│ │ +│ ├── schema/ # 请求/响应模型 (Pydantic) +│ │ +│ ├── service/ # 业务逻辑层 +│ │ ├── ai_service.py # AI 服务 +│ │ ├── chat_service.py # 对话服务 +│ │ ├── project_service.py # 项目服务 +│ │ ├── mysql_service.py # MySQL 服务 +│ │ └── ... +│ │ +│ ├── tasks/ # Celery 异步任务 +│ │ ├── __init__.py +│ │ └── ai_generation_tasks.py # AI 生成任务 +│ │ +│ ├── mysql/ # MySQL 相关 +│ ├── postgresql/ # PostgreSQL 相关 +│ ├── sqlite/ # SQLite 相关 +│ ├── redis_client/ # Redis 客户端 +│ └── ai/ # AI 模块 +│ +├── tests/ # 测试目录 +├── logs/ # 日志目录 +├── static/ # 静态文件 +├── config.yaml # 配置文件 +├── requirements.txt # Python 依赖 +└── Dockerfile.dev # Docker 开发配置 +``` + +## 快速开始 + +### 1. 环境准备 + +```bash +# 创建 conda 环境 +conda create -n backend python=3.10 -y + +# 激活环境 +conda activate backend + +# 配置国内镜像源(提升下载速度) +pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ + +# 安装依赖 +pip install -r requirements.txt +``` + +### 2. 启动服务 + +使用 Docker Compose 一键启动所有服务: + +```bash +# 在项目根目录执行 +docker-compose up -d + +# 查看服务状态 +docker-compose ps + +# 查看后端日志 +docker-compose logs -f backend +``` + +### 3. 访问 API 文档 + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## 开发指南 + +### 添加新的 API 路由 + +1. 在 `api/v1/endpoints/` 下创建新文件 +2. 在 `api/v1/api.py` 中注册路由 + +```python +# api/v1/endpoints/my_module.py +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/") +async def get_items(): + return {"items": []} +``` + +```python +# api/v1/api.py +from api.v1.endpoints import my_module + +api_router.include_router( + my_module.router, + prefix="/my-module", + tags=["My Module"] +) +``` + +### 添加新的 Celery 任务 + +详见 [Celery 任务迁移文档](celery任务迁移文档.md) 中的"开发者指南"部分。 + +**基本步骤:** + +1. 在 `tasks/` 目录下创建任务文件 +2. 在 `celery_app.py` 的 `include` 列表中注册 +3. **重启 Celery Worker** + +```python +# tasks/my_tasks.py +from celery_app import celery_app + +@celery_app.task(name="tasks.my_task", bind=True) +def my_task(self, param1: str): + # 任务逻辑 + return {"success": True} +``` + +```python +# celery_app.py +celery_app = Celery( + "app", + include=[ + "tasks.ai_generation_tasks", + "tasks.my_tasks", # 新增 + ] +) +``` + +### 数据库操作 + +使用 SQLAlchemy 异步 Session: + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from core.deps import get_db + +@router.get("/items") +async def get_items(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Item)) + return result.scalars().all() +``` + +### 依赖注入 + +常用依赖: + +```python +from core.deps import ( + get_db, # 数据库 Session + get_current_user, # 当前登录用户 + get_current_admin, # 当前管理员用户 + get_mysql_instance, # MySQL 实例 + get_postgres_instance, # PostgreSQL 实例 + get_sqlite_instance, # SQLite 实例 +) +``` + +## 测试 + +```bash +cd src/backend + +# 运行所有测试 +pytest + +# 运行特定测试文件 +pytest tests/service/test_chat_service.py + +# 带覆盖率报告 +pytest --cov=app --cov-report=html +``` + +## 常见问题 + +### Q: Windows 启动 Celery Worker 报错 + +使用 `--pool=solo` 参数: + +```bash +celery -A celery_app worker --loglevel=info --pool=solo +``` + +### Q: 修改 Celery 任务后不生效 + +**必须重启 Celery Worker**。Celery Worker 在启动时加载任务代码,修改后不会自动热重载。 + +### Q: 数据库连接池耗尽 + +检查是否正确关闭 Session: + +```python +async with get_session(engine) as session: + # 操作完成后自动关闭 + pass +``` + +### Q: Redis 连接失败 + +1. 确认 Redis 服务正在运行 +2. 检查 `config.yaml` 中的 Redis 配置 +3. 检查防火墙设置 + +## 相关文档 + +- [Redis 缓存设计文档](../../doc/project/02-设计文档/redis缓存设计文档.md) +- [API 接口文档](http://localhost:8000/docs) diff --git a/src/backend/app/__init__.py b/src/backend/app/__init__.py new file mode 100644 index 0000000..5992e8f --- /dev/null +++ b/src/backend/app/__init__.py @@ -0,0 +1 @@ +# backend/app/_init_.py \ No newline at end of file diff --git a/src/backend/app/ai/__init__.py b/src/backend/app/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/app/api/v1/__init__.py b/src/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/app/api/v1/api.py b/src/backend/app/api/v1/api.py new file mode 100644 index 0000000..210437a --- /dev/null +++ b/src/backend/app/api/v1/api.py @@ -0,0 +1,37 @@ +# backend/app/api/v1/api.py + +from fastapi import APIRouter + +# 引入所有 endpoints +from .endpoints import ( + auth, + project, + reports, + user, + admin, + knowledge, + chat, + session, + announcement, # <--- 1. 导入新模块 + database +) + +api_router = APIRouter() + +# ---------------------------------------------------- +# 路由注册区 +# ---------------------------------------------------- + +api_router.include_router(auth.router, prefix="/auth", tags=["I. 认证与授权"]) +api_router.include_router(project.router, prefix="/projects", tags=["II. 项目管理"]) +api_router.include_router(reports.router, tags=["III. 报表与查询"]) +api_router.include_router(knowledge.router, tags=["IV. 业务术语表"]) +api_router.include_router(user.router, prefix="/user", tags=["V. 用户设置"]) +api_router.include_router(chat.router, tags=["VII. 对话与消息管理"]) # chat 在 admin 之前,避免 /ai-models/options 被 admin 的 /ai-models/{config_id} 捕获 +api_router.include_router(admin.router, tags=["VI. 管理员功能"]) +api_router.include_router(session.router, prefix="/sessions", tags=["VIII. 会话管理"]) + +# 2. 注册公告模块 +# 这里的 prefix="/announcements" 完美对应了你文档中的 GET /announcements +api_router.include_router(announcement.router, prefix="/announcements", tags=["IX. 系统公告"]) +api_router.include_router(database.router, prefix="/database", tags=["X. 数据库管理"]) \ No newline at end of file diff --git a/src/backend/app/api/v1/deps.py b/src/backend/app/api/v1/deps.py new file mode 100644 index 0000000..728c569 --- /dev/null +++ b/src/backend/app/api/v1/deps.py @@ -0,0 +1,91 @@ +# backend/app/api/v1/deps.py + +from fastapi import Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession +from core.exceptions import ForbiddenException, ItemNotFoundException +from core.deps import get_db, oauth2_scheme # 从 core.deps 导入 oauth2_scheme +from core.auth import decode_jwt_token # 导入刚才纯净版的工具函数 +from crud.crud_user_account import crud_user_account +from schema.user import UserMe +from redis_client.redis import get_redis +from redis_client.redis_keys import redis_key_manager +from core.log import log + +# 1. 这一步只负责从 Header 拿 Token 字符串 +def get_token_str(token: str = Depends(oauth2_scheme)) -> str: + return token + +# 2. 这一步负责解析 Token 拿到 ID +async def get_current_user_id(token: str = Depends(get_token_str)) -> int: + # 增加 Redis 黑名单检查 (队友的逻辑) + redis = get_redis() + if redis: + is_valid = await redis.get(redis_key_manager.get_token_key(token)) + if not is_valid: + raise ForbiddenException(message="令牌已被撤销") + + # 解码 + return decode_jwt_token(token) + +# 3. 这一步查数据库拿到用户对象(包含频率检测) +async def get_current_active_user( + request: Request, + db: AsyncSession = Depends(get_db), + user_id: int = Depends(get_current_user_id) +) -> UserMe: + user_orm = await crud_user_account.get(db, user_id) + if not user_orm: + raise ItemNotFoundException(message="用户未找到") + + # 只有 banned 状态禁止访问 + if user_orm.status == "banned": + raise ForbiddenException(message="账户已被封禁,请联系管理员。") + + # suspended 状态只是标签,不限制功能,只记录日志 + if user_orm.status == "suspended": + log.info(f"用户 {user_id} 被标记为异常,但允许正常访问") + + # 检查其他非正常状态(除了 normal 和 suspended) + if user_orm.status not in ["normal", "suspended"]: + raise ForbiddenException(message="用户未激活") + + # 只对普通用户进行频率检测,管理员豁免 + if not user_orm.is_admin: + from core.security import freq_limiter, ViolationLogger + is_exceeded, count = await freq_limiter.check_frequency( + user_id, + time_window=10, + threshold=200 + ) + if is_exceeded: + log.warning("用户 {} 请求频率超限: {} 请求/10秒", user_id, count) + try: + from core.utils import get_client_ip + ip_address = get_client_ip(request) + await ViolationLogger.log_violation( + db, + user_id, + ViolationLogger.EVENT_EXCESSIVE_API_USAGE, + f"请求频率超限: {count} 请求在 10 秒内", + risk_level=ViolationLogger.RISK_MEDIUM, + ip_address=ip_address, + client_user_agent=request.headers.get("user-agent") + ) + await db.commit() + except Exception as e: + log.error("记录违规日志失败: {}", e) + # 不raise异常,不拦截请求 + else: + log.debug("用户 {} 是管理员,跳过频率检测", user_id) + + return UserMe.model_validate(user_orm) + + +async def get_current_admin_user(user: UserMe = Depends(get_current_active_user)) -> UserMe: + """ + 依赖函数:验证当前用户是否为管理员。 + """ + # 假设 UserMe DTO (或底层的 ORM) 包含 is_admin 字段 + if not user.is_admin: + raise ForbiddenException(message="操作禁止:需要管理员权限") + return user \ No newline at end of file diff --git a/src/backend/app/api/v1/endpoints/admin.py b/src/backend/app/api/v1/endpoints/admin.py new file mode 100644 index 0000000..8b840b0 --- /dev/null +++ b/src/backend/app/api/v1/endpoints/admin.py @@ -0,0 +1,619 @@ +""" +管理员 API 端点。提供用户管理、公告管理、违规记录查看及系统统计看板等管理员专属功能。 +""" +from typing import Optional, List, Literal, Any +from fastapi import APIRouter, Depends, Query, Path, Body, Request +from sqlalchemy.ext.asyncio import AsyncSession as Session + +# 导入 Service 和 Schema +from service import admin_service as service +from schema.admin import ( + AdminUserListResponse, AdminUpdateUserStatusRequest, AdminUpdateUserStatusResponse, + AdminUpdateUserQuotaRequest, AdminUpdateUserQuotaResponse, + AnnouncementCreateRequest, AnnouncementUpdateRequest, AnnouncementResponse, + AdminListResponse, AdminListItem, ViolationLogListResponse, AdminStatsResponse, + AdminUserDetailResponse +) +from schema.user import UserMe +from schema.unified_response import UnifiedResponse, PageData +# 导入依赖 +from api.v1.deps import get_db, get_current_admin_user +from core.exceptions import ItemNotFoundException +from core.exceptions import ValidationException + +router = APIRouter() + +# 统一管理员权限依赖 +AdminDependency = Depends(get_current_admin_user) + +# 统一分页参数 DTO +class PaginationParams: + def __init__( + self, + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页大小"), + ): + self.page = page + self.page_size = page_size + +# ---------------------------------------------------------------------- +# 4.1. 用户管理 +# ---------------------------------------------------------------------- +@router.get("/users", response_model=UnifiedResponse[PageData[List[AdminUserDetailResponse]]], summary="4.1.1 获取用户列表") +async def get_user_list( + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, + pagination: PaginationParams = Depends(), + search: Optional[str] = Query(None, description="按用户名/邮箱搜索"), + # 变量名改为 filter_status,增加 alias="status" + filter_status: Literal["normal", "suspended", "banned", "all"] = Query("all", alias="status", description="按状态筛选"), +) -> Any: + """ + 获取用户列表,支持搜索和筛选。 + + Args: + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + pagination (PaginationParams): 分页参数。 + search (Optional[str]): 按用户名/邮箱搜索关键字。 + filter_status (Literal["normal", "suspended", "banned", "all"]): 按状态筛选用户。 + + Returns: + UnifiedResponse[PageData[List[AdminUserDetailResponse]]]: 用户列表响应。 + """ + # 这里传入 filter_status + result = await service.get_admin_user_list_service( + db, pagination.page, pagination.page_size, search, filter_status + ) + page_data = PageData( + total=result.total, page=result.page, page_size=result.page_size, items=result.items + ) + return UnifiedResponse.success(data=page_data, message="获取用户列表成功") + + +@router.patch("/users/{user_id}/status", response_model=UnifiedResponse[AdminUpdateUserStatusResponse], summary="4.1.2 修改用户状态 (封禁/解封)") +async def update_user_status( + # 修正:将 data 移到 user_id 之前 + data: AdminUpdateUserStatusRequest, + user_id: int = Path(..., description="目标用户ID"), + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, +) -> Any: + """ + 封禁或解封用户。 + + Args: + data (AdminUpdateUserStatusRequest): 用户状态更新请求体。 + user_id (int): 目标用户ID。 + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[AdminUpdateUserStatusResponse]: 更新后的用户状态信息。 + """ + result = await service.update_user_status_service(db, user_id, data, admin_user.user_id) + return UnifiedResponse.success(data=result, message="修改用户状态成功") + + +@router.patch("/users/{user_id}/quota", response_model=UnifiedResponse[AdminUpdateUserQuotaResponse], summary="4.1.3 调整用户资源额度") +async def update_user_quota( + # 修正:将 data 移到 user_id 之前 + request: Request, # 添加 Request 依赖 + data: AdminUpdateUserQuotaRequest, + user_id: int = Path(..., description="目标用户ID"), + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, +) -> Any: + """ + 调整用户的最大数据库额度。 + + Args: + request: HTTP请求对象,用于检查原始请求体。 + data (AdminUpdateUserQuotaRequest): 用户额度更新请求体。 + user_id (int): 目标用户ID。 + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[AdminUpdateUserQuotaResponse]: 更新后的用户额度信息。 + """ + # 额外验证:检查原始请求体是否包含科学计数法或小数 + import json + + # 获取原始请求体以进行额外验证 + try: + body_bytes = await request.body() + body_str = body_bytes.decode('utf-8') + body_json = json.loads(body_str) + + max_databases_raw = body_json.get('max_databases') + + # 检查是否为科学计数法格式(字符串形式) + if isinstance(max_databases_raw, str) and ('e' in max_databases_raw.lower() or 'E' in max_databases_raw): + raise ValidationException("不支持科学计数法格式") + + # 检查是否为小数(字符串形式包含小数点) + if isinstance(max_databases_raw, str) and '.' in max_databases_raw: + raise ValidationException("额度必须为整数") + + # 检查是否为小数(数值形式且不是整数) + if isinstance(max_databases_raw, float) and not max_databases_raw.is_integer(): + raise ValidationException("额度必须为整数") + + except (json.JSONDecodeError, AttributeError): + # 如果无法获取原始请求体,则跳过额外验证 + pass + + result = await service.update_user_quota_service(db, user_id, data) + return UnifiedResponse.success(data=result, message="调整用户资源额度成功") + + +@router.get("/users/{user_id}", response_model=UnifiedResponse[AdminUserDetailResponse], summary="4.1.4 获取用户详情") +async def get_user_detail( + user_id: int = Path(..., description="目标用户ID"), + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, + project_limit: int = Query(100, ge=0, le=500, description="返回的项目条目数量上限"), + login_limit: int = Query(20, ge=0, le=200, description="返回的登录历史条目数量上限"), +) -> Any: + """管理员查看用户详情:额度、项目列表、登录历史。""" + result = await service.get_admin_user_detail_service( + db, user_id=user_id, project_limit=project_limit, login_limit=login_limit + ) + return UnifiedResponse.success(data=result, message="获取用户详情成功") + +# ---------------------------------------------------------------------- +# 4.2. 公告管理 +# ---------------------------------------------------------------------- + +@router.post("/announcements", response_model=UnifiedResponse[AnnouncementResponse], summary="4.2.1 创建公告") +async def create_announcement( + data: AnnouncementCreateRequest, + db: Session = Depends(get_db), + current_admin: UserMe = AdminDependency, +) -> Any: + """ + 创建新的系统公告。 + + Args: + data (AnnouncementCreateRequest): 公告创建请求体。 + db (Session): 数据库会话。 + current_admin (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[AnnouncementResponse]: 创建后的公告信息。 + """ + result = await service.create_announcement_service(db, data, current_admin.user_id) + return UnifiedResponse.success(data=result, message="创建公告成功") + + +@router.put("/announcements/{announcement_id}", response_model=UnifiedResponse[AnnouncementResponse], summary="4.2.2 更新公告") +async def update_announcement( + # 修正:将 data 移到 announcement_id 之前 + data: AnnouncementUpdateRequest, + announcement_id: int = Path(..., description="公告ID"), + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, +) -> Any: + """ + 更新公告内容和状态。 + + Args: + data (AnnouncementUpdateRequest): 公告更新请求体。 + announcement_id (int): 公告ID。 + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[AnnouncementResponse]: 更新后的公告信息。 + """ + result = await service.update_announcement_service(db, announcement_id, data) + return UnifiedResponse.success(data=result, message="更新公告成功") + + +@router.delete("/announcements/{announcement_id}", response_model=UnifiedResponse[None], summary="4.2.3 删除公告") +async def delete_announcement( + announcement_id: int = Path(..., description="公告ID"), + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, +) -> Any: + """ + 删除指定的公告。 + + Args: + announcement_id (int): 公告ID。 + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[None]: 删除结果响应。 + """ + await service.delete_announcement_service(db, announcement_id) + return UnifiedResponse.success(message="删除公告成功") + +# ---------------------------------------------------------------------- +# 4.3. 管理员状态 / 4.4. 违规记录与统计 +# ---------------------------------------------------------------------- + +@router.get("/admins", response_model=UnifiedResponse[PageData[List[AdminListItem]]], summary="4.3.1 获取管理员列表") +async def get_admin_list( + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, + pagination: PaginationParams = Depends(), +) -> Any: + """ + 获取所有管理员的列表。 + + Args: + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + pagination (PaginationParams): 分页参数。 + + Returns: + UnifiedResponse[PageData[List[AdminListItem]]]: 管理员列表响应。 + """ + result = await service.get_admin_list_service(db, pagination.page, pagination.page_size) + page_data = PageData( + total=result.total, page=result.page, page_size=result.page_size, items=result.items + ) + return UnifiedResponse.success(data=page_data, message="获取管理员列表成功") + + +@router.get("/violations", response_model=UnifiedResponse[PageData[List[Any]]], summary="4.4.2 获取违规记录列表") +async def get_violation_logs( + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, + pagination: PaginationParams = Depends(), + risk_level: Optional[Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"]] = Query(None, description="风险等级筛选"), + resolution_status: Optional[Literal["pending", "in_progress", "resolved", "ignored"]] = Query(None, description="处理状态筛选"), +) -> Any: + """ + 获取用户违规操作记录列表,支持筛选。 + + Args: + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + pagination (PaginationParams): 分页参数。 + risk_level (Optional[Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"]]): 风险等级筛选。 + resolution_status (Optional[Literal["pending", "in_progress", "resolved", "ignored"]]): 处理状态筛选。 + + Returns: + UnifiedResponse[PageData[List[Any]]]: 违规记录列表响应。 + """ + result = await service.get_violation_logs_service( + db, pagination.page, pagination.page_size, risk_level, resolution_status + ) + page_data = PageData( + total=result.total, page=result.page, page_size=result.page_size, items=result.items + ) + return UnifiedResponse.success(data=page_data, message="获取违规记录列表成功") + + +@router.get("/dashboard/stats", response_model=UnifiedResponse[AdminStatsResponse], summary="4.4.1 获取系统统计看板") +async def get_system_stats( + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, +) -> Any: + """ + 获取系统运行的实时统计数据。 + + Args: + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[AdminStatsResponse]: 系统统计响应。 + """ + result = await service.get_admin_stats_service(db) + return UnifiedResponse.success(data=result, message="获取系统统计数据成功") + + +# ---------------------------------------------------------------------- +# 4.5. AI 模型配置管理 +# ---------------------------------------------------------------------- + +from schema.ai_model_config import ( + AIModelConfigCreate, AIModelConfigUpdate, AIModelConfigResponse, + AIModelConfigDetailResponse, AIModelConfigListResponse, AIModelOptionsResponse, + AIModelTestConnectionRequest, AIModelTestConnectionResponse +) +from crud.crud_ai_model_config import crud_ai_model_config +import httpx +import time + + +@router.get("/ai-models", response_model=UnifiedResponse[AIModelConfigListResponse], summary="4.5.1 获取 AI 模型配置列表") +async def get_ai_model_configs( + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页大小"), +) -> Any: + """ + 获取 AI 模型配置列表。 + + Args: + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + page (int): 页码。 + page_size (int): 每页大小。 + + Returns: + UnifiedResponse[AIModelConfigListResponse]: 模型配置列表响应。 + """ + skip = (page - 1) * page_size + configs = await crud_ai_model_config.get_all(db, skip=skip, limit=page_size) + total = await crud_ai_model_config.get_count(db) + + items = [AIModelConfigResponse.model_validate(config) for config in configs] + result = AIModelConfigListResponse(total=total, items=items) + return UnifiedResponse.success(data=result, message="获取 AI 模型配置列表成功") + + +@router.get("/ai-models/{config_id}", response_model=UnifiedResponse[AIModelConfigDetailResponse], summary="4.5.2 获取 AI 模型配置详情") +async def get_ai_model_config( + config_id: int = Path(..., description="配置ID"), + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, +) -> Any: + """ + 获取指定 AI 模型配置的详细信息。 + + Args: + config_id (int): 配置ID。 + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[AIModelConfigDetailResponse]: 模型配置详情响应。 + """ + config = await crud_ai_model_config.get(db, config_id) + if not config: + raise ItemNotFoundException("AI 模型配置") + + # 脱敏 API Key:只显示前4位和后4位 + api_key = config.api_key + if len(api_key) > 8: + api_key_masked = api_key[:4] + "*" * (len(api_key) - 8) + api_key[-4:] + else: + api_key_masked = "*" * len(api_key) + + result = AIModelConfigDetailResponse( + config_id=config.config_id, + model_name=config.model_name, + api_url=config.api_url, + model_id=config.model_id, + model_type=config.model_type, + created_at=config.created_at, + updated_at=config.updated_at, + api_key_masked=api_key_masked + ) + return UnifiedResponse.success(data=result, message="获取 AI 模型配置详情成功") + + +@router.post("/ai-models", response_model=UnifiedResponse[AIModelConfigResponse], summary="4.5.3 创建 AI 模型配置") +async def create_ai_model_config( + data: AIModelConfigCreate, + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, +) -> Any: + """ + 创建新的 AI 模型配置。 + + Args: + data (AIModelConfigCreate): 创建请求数据。 + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[AIModelConfigResponse]: 创建的模型配置响应。 + """ + # 检查 model_name 是否已存在 + if await crud_ai_model_config.check_name_exists(db, data.model_name): + raise ValidationException(f"模型名称 '{data.model_name}' 已存在") + + config = await crud_ai_model_config.create(db, data) + result = AIModelConfigResponse.model_validate(config) + return UnifiedResponse.success(data=result, message="创建 AI 模型配置成功") + + +@router.put("/ai-models/{config_id}", response_model=UnifiedResponse[AIModelConfigResponse], summary="4.5.4 更新 AI 模型配置") +async def update_ai_model_config( + data: AIModelConfigUpdate, + config_id: int = Path(..., description="配置ID"), + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, +) -> Any: + """ + 更新指定的 AI 模型配置。 + + Args: + data (AIModelConfigUpdate): 更新请求数据。 + config_id (int): 配置ID。 + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[AIModelConfigResponse]: 更新后的模型配置响应。 + """ + # 检查配置是否存在 + existing = await crud_ai_model_config.get(db, config_id) + if not existing: + raise ItemNotFoundException("AI 模型配置") + + # 如果要更新 model_name,检查新名称是否与其他配置冲突 + if data.model_name and data.model_name != existing.model_name: + if await crud_ai_model_config.check_name_exists(db, data.model_name, exclude_id=config_id): + raise ValidationException(f"模型名称 '{data.model_name}' 已存在") + + config = await crud_ai_model_config.update(db, config_id, data) + result = AIModelConfigResponse.model_validate(config) + return UnifiedResponse.success(data=result, message="更新 AI 模型配置成功") + + +@router.delete("/ai-models/{config_id}", response_model=UnifiedResponse[None], summary="4.5.5 删除 AI 模型配置") +async def delete_ai_model_config( + config_id: int = Path(..., description="配置ID"), + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, +) -> Any: + """ + 删除指定的 AI 模型配置。 + + Args: + config_id (int): 配置ID。 + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[None]: 删除成功响应。 + """ + # 检查配置是否存在 + existing = await crud_ai_model_config.get(db, config_id) + if not existing: + raise ItemNotFoundException("AI 模型配置") + + await crud_ai_model_config.delete(db, config_id) + return UnifiedResponse.success(message="删除 AI 模型配置成功") + + +@router.post("/ai-models/test-connection", response_model=UnifiedResponse[AIModelTestConnectionResponse], summary="4.5.6 测试 AI 模型连接") +async def test_ai_model_connection( + data: AIModelTestConnectionRequest, + db: Session = Depends(get_db), + admin_user: UserMe = AdminDependency, +) -> Any: + """ + 测试 AI 模型 API 连接是否可用。 + + Args: + data (AIModelTestConnectionRequest): 测试连接请求数据。 + db (Session): 数据库会话。 + admin_user (UserMe): 当前管理员用户。 + + Returns: + UnifiedResponse[AIModelTestConnectionResponse]: 测试结果响应。 + """ + start_time = time.time() + + # 清理 URL 中的不可见字符(零宽空格、换行符等) + import re + clean_url = re.sub(r'[\x00-\x1f\x7f-\x9f\u200b-\u200d\ufeff]', '', data.api_url.strip()) + clean_model_id = data.model_id.strip() + + # 获取 API 密钥:优先使用传入的密钥,否则从数据库获取 + clean_api_key = None + if data.api_key and data.api_key.strip(): + clean_api_key = data.api_key.strip() + elif data.config_id: + # 从数据库获取已保存的密钥 + from crud.crud_ai_model_config import crud_ai_model_config + existing_config = await crud_ai_model_config.get(db, data.config_id) + if existing_config: + clean_api_key = existing_config.api_key + else: + return UnifiedResponse.success(AIModelTestConnectionResponse( + success=False, + message="配置不存在,无法获取已保存的密钥", + response_time_ms=0 + )) + + if not clean_api_key: + return UnifiedResponse.success(AIModelTestConnectionResponse( + success=False, + message="请提供 API 密钥", + response_time_ms=0 + )) + + try: + # 构造简单的测试请求 + async with httpx.AsyncClient(timeout=30.0) as client: + # 发送简单的对话请求测试连接 + test_payload = { + "model": clean_model_id, + "messages": [ + {"role": "user", "content": "Hi"} + ], + "max_tokens": 5, + "stream": False + } + + headers = { + "Authorization": f"Bearer {clean_api_key}", + "Content-Type": "application/json" + } + + response = await client.post( + clean_url, + json=test_payload, + headers=headers + ) + + response_time_ms = int((time.time() - start_time) * 1000) + + if response.status_code == 200: + result = AIModelTestConnectionResponse( + success=True, + message="连接成功,API 可正常使用", + response_time_ms=response_time_ms + ) + elif response.status_code == 401: + result = AIModelTestConnectionResponse( + success=False, + message="API 密钥无效或已过期", + response_time_ms=response_time_ms + ) + elif response.status_code == 404: + result = AIModelTestConnectionResponse( + success=False, + message=f"模型 ID '{data.model_id}' 不存在", + response_time_ms=response_time_ms + ) + elif response.status_code == 429: + result = AIModelTestConnectionResponse( + success=False, + message="请求频率超限,请稍后再试", + response_time_ms=response_time_ms + ) + else: + try: + resp_json = response.json() + # 尝试多种错误格式 + error_detail = ( + resp_json.get("error", {}).get("message", "") or + resp_json.get("message", "") or + resp_json.get("detail", "") or + resp_json.get("msg", "") or + str(resp_json)[:200] + ) + except: + error_detail = response.text[:200] if response.text else "未知错误" + result = AIModelTestConnectionResponse( + success=False, + message=f"请求失败 (HTTP {response.status_code}): {error_detail}", + response_time_ms=response_time_ms + ) + + except httpx.TimeoutException: + response_time_ms = int((time.time() - start_time) * 1000) + result = AIModelTestConnectionResponse( + success=False, + message="连接超时,请检查 API 地址是否正确", + response_time_ms=response_time_ms + ) + except httpx.ConnectError: + response_time_ms = int((time.time() - start_time) * 1000) + result = AIModelTestConnectionResponse( + success=False, + message="无法连接到服务器,请检查 API 地址", + response_time_ms=response_time_ms + ) + except Exception as e: + response_time_ms = int((time.time() - start_time) * 1000) + result = AIModelTestConnectionResponse( + success=False, + message=f"测试失败: {str(e)}", + response_time_ms=response_time_ms + ) + + return UnifiedResponse.success(data=result, message="测试完成") diff --git a/src/backend/app/api/v1/endpoints/admin_blacklist.py b/src/backend/app/api/v1/endpoints/admin_blacklist.py new file mode 100644 index 0000000..71a2140 --- /dev/null +++ b/src/backend/app/api/v1/endpoints/admin_blacklist.py @@ -0,0 +1,340 @@ +""" +管理员黑名单管理 API 端点。 + +提供管理员用户封禁、解封、频率限制监控等功能的 REST API。 +""" + +# backend/app/api/v1/endpoints/admin_blacklist.py + +from typing import Optional, List, Any +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession +from schema.unified_response import UnifiedResponse, PageData + +from core.deps import get_db, get_current_admin +from core.log import log +from models.user_account import UserAccount +from core.exceptions import ItemNotFoundException, ValidationException +from service.admin_service import ( + ban_user_service, + unban_user_service, + get_banned_users_service, + get_violation_stats_service, + get_frequency_limit_status_service, + reset_frequency_limit_service, +) + +# 创建路由器 +router = APIRouter( + prefix="/api/v1/admin/blacklist", + tags=["Admin - Blacklist Management"], + dependencies=[Depends(get_current_admin)] +) + + +# ============================================================================ +# 1. 用户黑名单操作 +# ============================================================================ + +@router.post("/ban/{user_id}", response_model=UnifiedResponse[dict]) +async def ban_user( + user_id: int, + reason: str = "违反服务条款", + current_admin: UserAccount = Depends(get_current_admin), + db: AsyncSession = Depends(get_db) +): + """ + 管理员手动封禁用户。 + + 触发条件: + - 管理员主动封禁(设置 status='banned') + + 注意: + - 系统自动检测三击机制仅会将用户标记为 suspended(异常用户) + - 真正的封禁(banned)需要管理员手动操作 + + 参数: + - user_id: 要封禁的用户ID + - reason: 封禁原因(可选,默认"违反服务条款") + + 返回: + { + "success": true, + "message": "用户 123 已被封禁", + "user_id": 123, + "reason": "违反服务条款", + "status": "banned" + } + """ + result = await ban_user_service( + db=db, + user_id=user_id, + reason=reason, + admin_id=current_admin.user_id + ) + return UnifiedResponse.success(data=result, message="用户封禁成功") + + +@router.post("/unban/{user_id}", response_model=UnifiedResponse[dict]) +async def unban_user( + user_id: int, + current_admin: UserAccount = Depends(get_current_admin), + db: AsyncSession = Depends(get_db) +): + """ + 管理员手动解封用户。 + + 效果: + - 将用户 status 设为 'normal' + - 清空 Redis 频率限制计数器 + + 说明: + - 此操作可以解除 banned(管理员封禁)或 suspended(系统标记)状态 + + 参数: + - user_id: 要解封的用户ID + + 返回: + { + "success": true, + "message": "用户 123 已被解封", + "user_id": 123, + "status": "normal" + } + """ + result = await unban_user_service( + db=db, + user_id=user_id, + admin_id=current_admin.user_id + ) + return UnifiedResponse.success(data=result, message="用户解封成功") + + +@router.get("/list", response_model=UnifiedResponse[PageData[List[dict]]]) +async def list_banned_users( + page: int = 1, + page_size: int = 20, + current_admin: UserAccount = Depends(get_current_admin), + db: AsyncSession = Depends(get_db) +): + """ + 获取被封禁/异常的用户列表。 + + 分页查询所有已被封禁(status='banned')或系统标记异常(status='suspended')的用户。 + + 参数: + - page: 页码(从 1 开始),默认 1 + - page_size: 每页数量,默认 20,最大 100 + + 返回: + { + "total": 5, + "page": 1, + "page_size": 20, + "users": [ + { + "user_id": 123, + "username": "violator_user", + "email": "user@example.com", + "status": "banned", + "status_desc": "管理员封禁", + "updated_at": "2025-12-23T10:30:00+00:00" + }, + { + "user_id": 456, + "username": "suspicious_user", + "email": "suspicious@example.com", + "status": "suspended", + "status_desc": "系统标记异常", + "updated_at": "2025-12-23T09:00:00+00:00" + } + ] + } + """ + # 参数验证 + page = max(1, page) + page_size = min(max(1, page_size), 100) # 限制最大 100 + + result = await get_banned_users_service( + db=db, + page=page, + page_size=page_size + ) + page_data = PageData( + total=result.get("total", 0), + page=result.get("page", page), + page_size=result.get("page_size", page_size), + items=result.get("users", []) + ) + return UnifiedResponse.success(data=page_data, message="获取封禁用户列表成功") + + +# ============================================================================ +# 2. 频率限制和违规监控 +# ============================================================================ + +@router.get("/frequency/{user_id}", response_model=UnifiedResponse[dict]) +async def get_frequency_status( + user_id: int, + current_admin: UserAccount = Depends(get_current_admin), + db: AsyncSession = Depends(get_db) +): + """ + 获取用户的频率限制状态。 + + 包含: + - 当前请求频率(Redis 计数器) + - 频率阈值和时间窗口 + - 24小时内的违规记录数 + - 用户状态(normal/suspended/banned) + + 参数: + - user_id: 用户ID + + 返回: + { + "user_id": 123, + "username": "test_user", + "current_frequency": 5, + "frequency_threshold": 20, + "time_window_seconds": 10, + "violation_count_24h": 2, + "auto_suspend_threshold": 3, + "status": "normal", + "is_banned": false, + "is_suspended": false + } + """ + result = await get_frequency_limit_status_service( + db=db, + user_id=user_id + ) + return UnifiedResponse.success(data=result, message="获取用户频率限制状态成功") + + +@router.post("/frequency/{user_id}/reset", response_model=UnifiedResponse[dict]) +async def reset_frequency( + user_id: int, + current_admin: UserAccount = Depends(get_current_admin), + db: AsyncSession = Depends(get_db) +): + """ + 重置用户的频率限制计数器。 + + 效果: + - 清空 Redis 中的 user:freq:{user_id} 键 + - 允许用户继续使用 API + + 参数: + - user_id: 用户ID + + 返回: + { + "success": true, + "message": "用户 123 的频率限制已重置", + "user_id": 123 + } + """ + result = await reset_frequency_limit_service( + db=db, + user_id=user_id, + admin_id=current_admin.user_id + ) + return UnifiedResponse.success(data=result, message="重置用户频率限制成功") + + +@router.get("/violations/stats", response_model=UnifiedResponse[dict]) +async def get_violation_statistics( + hours: int = 24, + current_admin: UserAccount = Depends(get_current_admin), + db: AsyncSession = Depends(get_db) +): + """ + 获取违规统计信息。 + + 统计指定时间范围内的所有违规事件,按风险等级和事件类型分类。 + + 参数: + - hours: 统计时间范围(小时),默认 24 + + 返回: + { + "time_range_hours": 24, + "total_violations": 15, + "risk_level_distribution": { + "CRITICAL": 0, + "HIGH": 5, + "MEDIUM": 10, + "LOW": 0 + }, + "event_type_distribution": { + "excessive_api_usage": 8, + "multiple_failed_logins": 4, + "frequent_remote_login": 3 + }, + "pending_violations": 10 + } + """ + # 参数验证 + hours = max(1, min(hours, 720)) # 限制 1-30 天 + + result = await get_violation_stats_service( + db=db, + hours=hours + ) + return UnifiedResponse.success(data=result, message="获取违规统计信息成功") + + +# ============================================================================ +# 3. 批量操作和报告 +# ============================================================================ + +@router.get("/dashboard", response_model=UnifiedResponse[dict]) +async def get_blacklist_dashboard( + current_admin: UserAccount = Depends(get_current_admin), + db: AsyncSession = Depends(get_db) +): + """ + 获取黑名单管理看板数据。 + + 综合显示: + - 被封禁用户总数 + - 24小时内的违规统计 + - 风险等级分布 + - 事件类型分布 + + 返回: + { + "banned_users_count": 5, + "violation_stats": { + "time_range_hours": 24, + "total_violations": 15, + "risk_level_distribution": {...}, + "event_type_distribution": {...}, + "pending_violations": 10 + }, + "timestamp": "2025-12-23T10:30:00+00:00" + } + """ + from datetime import datetime, timezone + + # 获取被封禁用户数 + banned_users_response = await get_banned_users_service( + db=db, + page=1, + page_size=1 + ) + banned_users_count = banned_users_response.get("total", 0) + + # 获取违规统计 + violation_stats = await get_violation_stats_service( + db=db, + hours=24 + ) + + result = { + "banned_users_count": banned_users_count, + "violation_stats": violation_stats, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + return UnifiedResponse.success(data=result, message="获取黑名单管理看板数据成功") diff --git a/src/backend/app/api/v1/endpoints/announcement.py b/src/backend/app/api/v1/endpoints/announcement.py new file mode 100644 index 0000000..bb5bf2b --- /dev/null +++ b/src/backend/app/api/v1/endpoints/announcement.py @@ -0,0 +1,112 @@ +"""公告 API 端点。 + +提供系统公告的列表查询和详情获取接口。 + +- 普通登录用户:仅能获取已发布(published)公告 +- 管理员:可按 status 获取(含草稿等) +""" + +from typing import Any, Literal, List + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from api.v1.deps import get_db, get_current_active_user +from schema.user import UserMe +from crud.crud_announcement import crud_announcement +from schema.announcement import ( + AnnouncementListResponse, + AnnouncementDetailResponse +) +from schema.unified_response import UnifiedResponse, PageData +from core.exceptions import ItemNotFoundException, OperationNotPermittedException + +router = APIRouter() + + +# ============================ +# 1. 获取公告列表(登录用户) +# ============================ +@router.get( + "/", + response_model=UnifiedResponse[PageData[List[AnnouncementDetailResponse]]], + summary="获取公告列表" +) +async def read_announcements( + db: AsyncSession = Depends(get_db), + current_user: UserMe = Depends(get_current_active_user), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(10, ge=1, le=100, description="每页数量"), + status: Literal['draft', 'published', 'unpublished', 'expired'] | None = Query( + None, + description="公告状态筛选(仅管理员可用;普通用户固定为 published)", + ), +): + """ + 获取已发布的系统公告(仅限登录用户)。 + + Args: + db (AsyncSession): 数据库会话。 + current_user (UserMe): 当前登录用户。 + page (int): 页码。 + page_size (int): 每页数量。 + + Returns: + AnnouncementListResponse: 公告列表响应。 + """ + # 安全策略:普通用户强制仅可见已发布公告 + # 管理员可按 status 查询;不传 status 时返回全部状态 + effective_status = status if current_user.is_admin else "published" + + total, items = await crud_announcement.get_list( + db=db, + status=effective_status, + page=page, + page_size=page_size, + ) + + page_data = PageData( + total=total, + page=page, + page_size=page_size, + items=items + ) + return UnifiedResponse.success(data=page_data, message="获取公告列表成功") + + +# ============================ +# 2. 获取公告详情(登录用户) +# ============================ +@router.get( + "/{announcement_id}", + response_model=UnifiedResponse[AnnouncementDetailResponse], + summary="获取公告详情" +) +async def get_announcement_detail( + announcement_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserMe = Depends(get_current_active_user), +): + """ + 获取公告详情(仅限已发布公告)。 + + Args: + announcement_id (int): 公告 ID。 + db (AsyncSession): 数据库会话。 + current_user (UserMe): 当前登录用户。 + + Returns: + AnnouncementDetailResponse: 公告详情。 + + Raises: + HTTPException: 公告不存在(404)或未发布(403)。 + """ + announcement = await crud_announcement.get(db, announcement_id) + + if not announcement: + raise ItemNotFoundException("Announcement not found") + + if announcement.status != "published": + raise OperationNotPermittedException("Announcement not published") + + return UnifiedResponse.success(data=announcement, message="获取公告详情成功") diff --git a/src/backend/app/api/v1/endpoints/auth.py b/src/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..f31fdf6 --- /dev/null +++ b/src/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,285 @@ +""" +认证 API 端点。 + +处理用户注册、登录(包括 Swagger UI)、Token 刷新、登出及验证码发送。 +""" +# backend/app/api/v1/endpoints/auth.py + +from fastapi import APIRouter, Depends, status, Body, Request +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Any +from schema.unified_response import UnifiedResponse, LoginData +from schema.auth import UserSendCode, UserRegister, UserLogin, ForgotPasswordRequest, ResetPasswordRequest +from fastapi.security import OAuth2PasswordRequestForm +from schema.token import Token # 记得导入这个 +from api.v1.deps import get_db +from core import exceptions +from service import email_service +from service.user_service import service_register_user, service_login_with_record, create_login_record, \ + check_email_exists, service_save_token_in_redis, service_logout,service_check_user_exists,service_save_token_in_redis +from service.password_reset_service import service_send_password_reset_code, service_reset_password_with_code +from core.auth import create_access_token +# :从 core.deps 导入 oauth2_scheme +from core.deps import get_db, oauth2_scheme +from core.log import log + +# :将 auth_router 改为 router,保持与其他模块一致 +router = APIRouter() + +# ============================================================ +# 给 Swagger UI Authorize 按钮使用的登录接口 +# ============================================================ +@router.post("/swagger_login", response_model=Token) +async def swagger_login( + request: Request, + db: AsyncSession = Depends(get_db), + form_data: OAuth2PasswordRequestForm = Depends() +): + """ + 专门兼容 OAuth2 表单格式的登录接口。 + + Args: + request (Request): HTTP请求对象 + db (AsyncSession): 数据库会话。 + form_data (OAuth2PasswordRequestForm): OAuth2 表单数据。 + + Returns: + Token: 访问令牌。 + + Raises: + HTTPException: 用户名密码错误(400)或 Token 保存失败(500)。 + """ + # 1. 验证用户 + try: + user = await service_login_with_record( + db, + request, + form_data.username, + form_data.password + ) + except Exception: + # service_login_with_record 已经处理了异常和日志记录 + raise + + if user.status != 'normal': + from core.exceptions import UserStatusForbiddenException + raise UserStatusForbiddenException(status=user.status, user_id=user.user_id) + + # 2. 生成 Token + access_token = create_access_token(data={"sub": str(user.user_id)}) + + # 3. 将 Token 存入 Redis 白名单 + # 如果不存,api/v1/deps.py 会因为查不到记录而报 Token revoked + if not await service_save_token_in_redis(access_token): + raise exceptions.RedisOperationFailedException() + + # 4. 设置用户在线状态 + from service.user_service import service_set_user_online_status + await service_set_user_online_status(user.user_id, is_online=True) + + # 5. 返回 Token + return { + "access_token": access_token, + "token_type": "bearer" + } + +# :所有的装饰器 @auth_router.xxx 都改为 @router.xxx +@router.post("/register/send-code", response_model=UnifiedResponse[None]) +async def send_register_code( + payload: UserSendCode, + db: AsyncSession = Depends(get_db) +): + """ + 发送注册验证码。 + + Args: + payload (UserSendCode): 请求载荷,包含邮箱地址。 + db (AsyncSession): 数据库会话。 + + Returns: + UnifiedResponse: 发送成功响应。 + + Raises: + HTTPException: 邮箱已注册或发送失败。 + """ + log.info("send register code") + existing_user = await check_email_exists(db, payload.email) + if existing_user: + raise exceptions.EmailHasBeenRegisteredException() + await email_service.service_send_verification_code(payload.email) + log.info("send register code success") + return UnifiedResponse.success(message="验证码发送成功") + + +@router.post("/register", response_model=UnifiedResponse[dict]) +async def register( + payload: UserRegister, + db: AsyncSession = Depends(get_db) +): + """ + 用户注册接口。 + + Args: + payload (UserRegister): 注册请求载荷。 + db (AsyncSession): 数据库会话。 + + Returns: + UnifiedResponse: 注册成功的用户信息。 + + Raises: + HTTPException: 注册失败。 + """ + new_user = await service_register_user( + db, + username=payload.username, + email=payload.email, + password=payload.password, + code=payload.verification_code + ) + + log.info("Registered user {}", new_user.username) + return UnifiedResponse.success( + data={ + "user_id": new_user.user_id, + "username": new_user.username, + "email": new_user.email, + "status": new_user.status, + "created_at": new_user.created_at + }, + message="用户注册成功" + ) + + +@router.post("/login", response_model=UnifiedResponse[LoginData]) +async def login( + request: Request, + payload: UserLogin, + db: AsyncSession = Depends(get_db) +): + """ + 用户登录接口。 + + 验证用户、生成Token并记录登录日志。 + + Args: + request (Request): HTTP请求对象。 + payload (UserLogin): 登录请求载荷。 + db (AsyncSession): 数据库会话。 + + Returns: + UnifiedResponse: 包含Token和用户信息的响应。 + """ + # 初始化变量:存储登录记录需要的信息 + from core.utils import get_client_ip + client_ip = get_client_ip(request) + user_agent = request.headers.get("User-Agent", "") + device_info = request.headers.get("X-Device-Info", "") + log.info("Login request: client_ip={}, user_agent={}, device_infp={}", client_ip, user_agent, device_info) + + exist_user = await service_login_with_record( + db=db, + request=request, + username=payload.username, + password=payload.password + ) + + # 异地频繁登录检测已禁用 + # from core.security import RemoteLoginDetector, ViolationLogger + # is_remote_login, remote_login_desc = await RemoteLoginDetector.detect_remote_login( + # db=db, + # user_id=exist_user.user_id, + # current_ip=client_ip + # ) + # + # if is_remote_login: + # # 记录异地频繁登录违规 + # try: + # await ViolationLogger.log_violation( + # db=db, + # user_id=exist_user.user_id, + # event_type=ViolationLogger.EVENT_FREQUENT_REMOTE_LOGIN, + # event_description=remote_login_desc, + # risk_level=ViolationLogger.RISK_HIGH, + # ip_address=client_ip, + # client_user_agent=user_agent + # ) + # await db.commit() + # log.warning(f"用户 {exist_user.user_id} 因异地频繁登录被标记为异常") + # except Exception as e: + # log.error(f"记录异地登录违规失败: {e}") + # # 记录失败不影响登录流程继续 + + access_token = create_access_token(data={"sub": f"{exist_user.user_id}"}) + if not await service_save_token_in_redis(access_token): + log.error("无法在Redis中保存令牌") + raise exceptions.RedisOperationFailedException() + + # 设置用户在线状态 + from service.user_service import service_set_user_online_status + await service_set_user_online_status(exist_user.user_id, is_online=True) + + # 获取完整的用户信息 + from service import user_service + user_info = await user_service.get_user_me_service(db, exist_user.user_id) + + return UnifiedResponse.success( + data=LoginData( + access_token=access_token, + token_type="bearer", + user=user_info.dict() + ), + message="用户登录成功" + ) + +@router.post("/logout", response_model=UnifiedResponse[None]) +async def logout( + db: AsyncSession = Depends(get_db), + token: str = Depends(oauth2_scheme) +): + """ + 用户登出接口。 + + 废除 Token 并记录登出日志。 + + Args: + db (AsyncSession): 数据库会话。 + token (str): JWT 访问令牌。 + + Returns: + UnifiedResponse: 登出成功响应。 + + Raises: + HTTPException: 登出失败。 + """ + result = await service_logout(db, token) + if not result: + raise exceptions.RedisOperationFailedException() + + return UnifiedResponse.success(message="用户登出成功") + + +@router.post("/forgot-password", response_model=UnifiedResponse[None]) +async def forgot_password( + request: Request, + payload: ForgotPasswordRequest, + db: AsyncSession = Depends(get_db), +): + """忘记密码:请求发送重置邮件。 + + 为避免用户枚举,无论邮箱是否存在,对外均返回成功。 + """ + from core.utils import get_client_ip + client_ip = get_client_ip(request) + await service_send_password_reset_code(db, payload.email, client_ip=client_ip) + return UnifiedResponse.success(message="重置邮件发送成功") + + +@router.post("/reset-password", response_model=UnifiedResponse[None]) +async def reset_password( + payload: ResetPasswordRequest, + db: AsyncSession = Depends(get_db), +): + """重置密码:校验邮箱验证码并设置新密码。""" + + await service_reset_password_with_code(db, payload.email, payload.verification_code, payload.new_password) + return UnifiedResponse.success(message="密码重置成功") \ No newline at end of file diff --git a/src/backend/app/api/v1/endpoints/chat.py b/src/backend/app/api/v1/endpoints/chat.py new file mode 100644 index 0000000..03f8e6a --- /dev/null +++ b/src/backend/app/api/v1/endpoints/chat.py @@ -0,0 +1,179 @@ +""" +聊天 API 端点。 + +处理用户发送消息、获取历史记录,以及与 AI 模型的交互逻辑。 +""" + +import sqlparse +from fastapi import APIRouter, Depends, Path +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List, Any + +# 1. 导入核心工具 +from core.log import log +from service.chat_service import process_chat, confirm_and_execute_sql +from service.chat_service import cancel_message +from crud.crud_message import crud_message +from core.exceptions import ItemNotFoundException + +# 3. 导入 Schema +from schema.chat import ChatResponse, ChatRequest, MessageType +from schema.user import UserMe +from schema.unified_response import UnifiedResponse +from api.v1.deps import get_current_active_user, get_db + +router = APIRouter() + +# backend/app/api/v1/endpoints/chat.py + +# backend/app/api/v1/endpoints/chat.py + +def _format_history_response(raw_messages: List[Any]) -> List[ChatResponse]: + clean_history = [] + for msg in raw_messages: + # 1. 直接获取在 CRUD 中挂载好的持久化数据 + # 我们不再依赖 ai_statement,而是直接拿映射好的值 + sql_text = getattr(msg, 'sql_text', None) + sql_type = getattr(msg, 'sql_type', 'UNKNOWN') + data = getattr(msg, 'data', None) # <--- 获取持久化结果 + + msg_type = MessageType.ASSISTANT if msg.message_type == "assistant" else MessageType.USER + + # 2. 如果 SQL 信息还没挂载上(比如某些异常情况),再尝试兜底解析 + if msg_type == MessageType.ASSISTANT and not sql_text and msg.content: + try: + # 你的原始兜底逻辑保持不变,但增加安全性 + content = msg.content + if "已生成查询语句" in content: + sql_text = content.split(":")[-1].strip() + except: pass + + requires_conf = sql_type in ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"] + if msg.user_confirmed: requires_conf = False + + # 3. 核心修复:把 data 传进去! + clean_history.append(ChatResponse( + message_id=msg.message_id, + content=msg.content, + message_type=msg_type, + sql_text=sql_text, + sql_type=sql_type, + requires_confirmation=requires_conf, + data=data # <--- 修改这里:不再是 None,而是 msg.data + )) + return clean_history + +@router.post("/sessions/{session_id}/messages", response_model=UnifiedResponse[ChatResponse]) +async def send_message( + session_id: int = Path(...), + chat_request: ChatRequest = None, + db: AsyncSession = Depends(get_db), + current_user: UserMe = Depends(get_current_active_user) +): + """ + 发送消息给 AI,并获取 SQL 生成结果。 + + Args: + session_id (int): 会话 ID。 + chat_request (ChatRequest): 聊天请求体。 + db (AsyncSession): 数据库会话。 + current_user (UserMe): 当前登录用户。 + + Returns: + UnifiedResponse[ChatResponse]: AI 响应结果。 + """ + result = await process_chat( + db=db, session_id=session_id, user_input=chat_request.content, + user_id=current_user.user_id, selected_model=chat_request.model + ) + return UnifiedResponse.success(data=result, message="消息处理成功") + +@router.get("/sessions/{session_id}/messages", response_model=UnifiedResponse[List[ChatResponse]]) +async def get_history( + session_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserMe = Depends(get_current_active_user) +): + """ + 获取会话历史消息。 + + Args: + session_id (int): 会话 ID。 + db (AsyncSession): 数据库会话。 + current_user (UserMe): 当前登录用户。 + + Returns: + UnifiedResponse[List[ChatResponse]]: 历史消息列表。 + """ + raw_messages = await crud_message.get_recent_messages(db, session_id, limit=50) + result = _format_history_response(raw_messages) + return UnifiedResponse.success(data=result, message="获取历史消息成功") + +@router.post("/messages/{message_id}/confirm", response_model=UnifiedResponse[ChatResponse]) +async def confirm_message_execution( + message_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserMe = Depends(get_current_active_user) +): + log.info("User {} confirming message {}", current_user.user_id, message_id) + result = await confirm_and_execute_sql(db, message_id, current_user.user_id) + + # 根据执行结果返回不同的消息 + if result.sql_type == "ERROR": + return UnifiedResponse.success(data=result, message="SQL 执行失败") + else: + return UnifiedResponse.success(data=result, message="SQL 执行成功") + + +@router.post("/messages/{message_id}/cancel", response_model=UnifiedResponse[ChatResponse]) +async def cancel_message_request( + message_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserMe = Depends(get_current_active_user) +): + """取消一条需要确认的消息,并返回持久化的取消提示。""" + import traceback + try: + log.info("User {} cancelling message {}", current_user.user_id, message_id) + result = await cancel_message(db, message_id, current_user.user_id) + return UnifiedResponse.success(data=result, message="操作已取消") + except Exception as e: + log.error("Cancel message error: {}", traceback.format_exc()) + raise + + +# ---------------------------------------------------------------------- +# AI 模型选项(供用户选择模型使用) +# ---------------------------------------------------------------------- + +from schema.ai_model_config import AIModelOption, AIModelOptionsResponse +from crud.crud_ai_model_config import crud_ai_model_config + + +@router.get("/ai-models/options", response_model=UnifiedResponse[AIModelOptionsResponse]) +async def get_ai_model_options( + db: AsyncSession = Depends(get_db), + current_user: UserMe = Depends(get_current_active_user) +): + """ + 获取可用的 AI 模型选项列表(供前端下拉框使用)。 + + Args: + db (AsyncSession): 数据库会话。 + current_user (UserMe): 当前登录用户。 + + Returns: + UnifiedResponse[AIModelOptionsResponse]: 模型选项列表。 + """ + configs = await crud_ai_model_config.get_all(db) + + items = [ + AIModelOption( + model_name=config.model_name, + model_type=config.model_type + ) + for config in configs + ] + + result = AIModelOptionsResponse(items=items) + return UnifiedResponse.success(data=result, message="获取 AI 模型选项成功") \ No newline at end of file diff --git a/src/backend/app/api/v1/endpoints/database.py b/src/backend/app/api/v1/endpoints/database.py new file mode 100644 index 0000000..ed2d38b --- /dev/null +++ b/src/backend/app/api/v1/endpoints/database.py @@ -0,0 +1,361 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List, Dict, Any, Optional +from schema.unified_response import UnifiedResponse + +from api.v1 import deps +from core.exceptions import ItemNotFoundException, OperationNotPermittedException, ValidationException +from crud.crud_project import crud_project +from crud.crud_database_instance import crud_database_instance +from models.session import Session as SessionModel +from service.mysql_service import execute_mysql_sql_with_user_check as execute_mysql +from sqlalchemy.future import select +from service.postgresql_service import execute_postgres_sql_with_user_check as execute_postgres +from service.sqlite_service import execute_sqlite_sql_with_user_check as execute_sqlite + +router = APIRouter() + + +async def get_db_instance_by_project(db: AsyncSession, project_id: int, user_id: int): + """ + 通过 project_id 获取数据库实例,验证用户权限。 + """ + # 1. 获取项目 + project = await crud_project.get(db, project_id) + if not project: + raise ItemNotFoundException("Project not found") + + # 2. 验证用户权限 + if project.user_id != user_id: + raise OperationNotPermittedException("Not authorized") + + # 3. 获取数据库实例 + database_instance = await crud_database_instance.get(db, project.instance_id) + if not database_instance: + raise ItemNotFoundException("Database instance not found") + + return database_instance + + +async def get_db_instance_by_session(db: AsyncSession, session_id: int, user_id: int): + """ + Helper function to verify session ownership and retrieve the associated database instance. + """ + # 1. Verify session ownership + stmt = select(SessionModel).where(SessionModel.session_id == session_id) + result = await db.execute(stmt) + session_obj = result.scalar_one_or_none() + if not session_obj: + raise ItemNotFoundException("Session not found") + + # Assuming session ownership check is needed, though chat_service checks project ownership. + # Here we check if the session belongs to the user indirectly via project or directly if session has user_id. + # Based on chat_service.py: project_id = await _verify_session_ownership(db, session_id, user_id) + # Let's replicate that logic or similar. + # SessionModel usually has user_id? Let's check models/session.py if needed, but chat_service uses _verify_session_ownership. + # Let's assume simple check: session.user_id == user_id (if exists) or project check. + # For now, let's trust the session_obj has user_id or we check project access. + # chat_service.py: + # stmt = select(ProjectMember).where(ProjectMember.project_id == project_id, ProjectMember.user_id == user_id) + # Let's stick to a simpler check if possible, or just check session.user_id if it exists. + # If SessionModel doesn't have user_id, we need to check project membership. + + # Let's check if session_obj has user_id. + if hasattr(session_obj, 'user_id') and session_obj.user_id != user_id: + raise OperationNotPermittedException("Not authorized") + + # 2. Get Project + project = await crud_project.get(db, session_obj.project_id) + if not project: + raise ItemNotFoundException("Project not found") + + # 3. Get Database Instance + database_instance = await crud_database_instance.get(db, project.instance_id) + if not database_instance: + raise ItemNotFoundException("Database instance not found") + + return database_instance + +@router.get("/{session_id}/tables", response_model=UnifiedResponse[List[Dict[str, Any]]]) +async def get_tables( + session_id: int, + db: AsyncSession = Depends(deps.get_db), + current_user = Depends(deps.get_current_active_user), +): + """ + Get all tables in the database associated with the session. + """ + instance = await get_db_instance_by_session(db, session_id, current_user.user_id) + + tables = [] + + # === MySQL === + if instance.db_type == 'mysql': + sql = "SHOW FULL TABLES WHERE Table_Type = 'BASE TABLE'" + result = await execute_mysql(sql, "SELECT", instance) + if result: + for row in result: + # MySQL 返回字典: {'Tables_in_db': 'users', 'Table_type': 'BASE TABLE'} + values = list(row.values()) + if values: + tables.append({"name": values[0]}) + + # === PostgreSQL === + elif instance.db_type == 'postgresql': + sql = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'" + result = await execute_postgres(sql, "SELECT", instance) + if result: + for row in result: + # PG 返回字典: {'table_name': 'users'} + tables.append({"name": row.get('table_name')}) + + # === SQLite === + elif instance.db_type == 'sqlite': + sql = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + # 传入 current_user.user_id + result = await execute_sqlite(sql, "SELECT", instance, current_user.user_id) + if result: + for row in result: + # SQLite 返回字典: {'name': 'users'} + tables.append({"name": row.get('name')}) + + else: + raise ValidationException(f"Unsupported DB type: {instance.db_type}") + + return UnifiedResponse.success(data=tables, message="获取数据库表列表成功") + +@router.get("/{session_id}/tables/{table_name}/schema", response_model=UnifiedResponse[List[Dict[str, Any]]]) +async def get_table_schema( + session_id: int, + table_name: str, + db: AsyncSession = Depends(deps.get_db), + current_user = Depends(deps.get_current_active_user), +): + """ + Get the schema (columns) of a specific table. + """ + instance = await get_db_instance_by_session(db, session_id, current_user.user_id) + + # Basic validation + if not table_name.isidentifier(): + raise ValidationException("无效的表名") + + schema = [] + # === MySQL === + if instance.db_type == 'mysql': + sql = f"DESCRIBE `{table_name}`" + result = await execute_mysql(sql, "SELECT", instance) + if result: + for row in result: + # 统一转小写: field, type, null, key, default, extra + schema.append({k.lower(): v for k, v in row.items()}) + + # === PostgreSQL === + elif instance.db_type == 'postgresql': + sql = f"SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = '{table_name}' AND table_schema = 'public'" + result = await execute_postgres(sql, "SELECT", instance) + if result: + for row in result: + # 映射为前端通用字段名 + schema.append({ + "field": row.get("column_name"), + "type": row.get("data_type"), + "null": row.get("is_nullable"), + "default": row.get("column_default") + }) + + # === SQLite === + elif instance.db_type == 'sqlite': + sql = f"SELECT * FROM pragma_table_info('{table_name}')" + # 传入 current_user.user_id + result = await execute_sqlite(sql, "SELECT", instance, current_user.user_id) + if result: + for row in result: + # SQLite: cid, name, type, notnull, dflt_value, pk + schema.append({ + "field": row.get("name"), + "type": row.get("type"), + "null": "NO" if row.get("notnull") else "YES", + "default": row.get("dflt_value") + }) + + return UnifiedResponse.success(data=schema, message="获取表结构成功") + +@router.get("/{session_id}/tables/{table_name}/data", response_model=UnifiedResponse[List[Dict[str, Any]]]) +async def get_table_data( + session_id: int, + table_name: str, + limit: int = 100, + offset: int = 0, + db: AsyncSession = Depends(deps.get_db), + current_user = Depends(deps.get_current_active_user), +): + """ + Get data from a specific table with pagination. + """ + instance = await get_db_instance_by_session(db, session_id, current_user.user_id) + + if not table_name.isidentifier(): + raise ValidationException("无效的表名") + + limit = min(limit, 1000) + + # 构建 SQL (注意不同数据库的引号区别) + if instance.db_type == 'mysql': + sql = f"SELECT * FROM `{table_name}` LIMIT {limit} OFFSET {offset}" + result = await execute_mysql(sql, "SELECT", instance) + + elif instance.db_type == 'postgresql': + sql = f'SELECT * FROM "{table_name}" LIMIT {limit} OFFSET {offset}' + result = await execute_postgres(sql, "SELECT", instance) + + elif instance.db_type == 'sqlite': + sql = f'SELECT * FROM "{table_name}" LIMIT {limit} OFFSET {offset}' + # 传入 current_user.user_id + result = await execute_sqlite(sql, "SELECT", instance, current_user.user_id) + # ... + + else: + return [] + + return UnifiedResponse.success(data=result or [], message="获取表数据成功") + + +# ========================================================= +# 基于 Project ID 的数据库访问 API(无需会话) +# ========================================================= + +@router.get("/project/{project_id}/tables", response_model=UnifiedResponse[List[Dict[str, Any]]]) +async def get_tables_by_project( + project_id: int, + db: AsyncSession = Depends(deps.get_db), + current_user = Depends(deps.get_current_active_user), +): + """ + 通过项目 ID 获取数据库中的所有表(无需创建会话)。 + """ + instance = await get_db_instance_by_project(db, project_id, current_user.user_id) + + tables = [] + + # === MySQL === + if instance.db_type == 'mysql': + sql = "SHOW FULL TABLES WHERE Table_Type = 'BASE TABLE'" + result = await execute_mysql(sql, "SELECT", instance) + if result: + for row in result: + values = list(row.values()) + if values: + tables.append({"name": values[0]}) + + # === PostgreSQL === + elif instance.db_type == 'postgresql': + sql = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'" + result = await execute_postgres(sql, "SELECT", instance) + if result: + for row in result: + tables.append({"name": row.get('table_name')}) + + # === SQLite === + elif instance.db_type == 'sqlite': + sql = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + result = await execute_sqlite(sql, "SELECT", instance, current_user.user_id) + if result: + for row in result: + tables.append({"name": row.get('name')}) + + else: + raise ValidationException(f"Unsupported DB type: {instance.db_type}") + + return UnifiedResponse.success(data=tables, message="获取数据库表列表成功") + + +@router.get("/project/{project_id}/tables/{table_name}/schema", response_model=UnifiedResponse[List[Dict[str, Any]]]) +async def get_table_schema_by_project( + project_id: int, + table_name: str, + db: AsyncSession = Depends(deps.get_db), + current_user = Depends(deps.get_current_active_user), +): + """ + 通过项目 ID 获取指定表的结构(无需创建会话)。 + """ + instance = await get_db_instance_by_project(db, project_id, current_user.user_id) + + if not table_name.isidentifier(): + raise ValidationException("无效的表名") + + schema = [] + + # === MySQL === + if instance.db_type == 'mysql': + sql = f"DESCRIBE `{table_name}`" + result = await execute_mysql(sql, "SELECT", instance) + if result: + for row in result: + schema.append({k.lower(): v for k, v in row.items()}) + + # === PostgreSQL === + elif instance.db_type == 'postgresql': + sql = f"SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = '{table_name}' AND table_schema = 'public'" + result = await execute_postgres(sql, "SELECT", instance) + if result: + for row in result: + schema.append({ + "field": row.get("column_name"), + "type": row.get("data_type"), + "null": row.get("is_nullable"), + "default": row.get("column_default") + }) + + # === SQLite === + elif instance.db_type == 'sqlite': + sql = f"SELECT * FROM pragma_table_info('{table_name}')" + result = await execute_sqlite(sql, "SELECT", instance, current_user.user_id) + if result: + for row in result: + schema.append({ + "field": row.get("name"), + "type": row.get("type"), + "null": "NO" if row.get("notnull") else "YES", + "default": row.get("dflt_value") + }) + + return UnifiedResponse.success(data=schema, message="获取表结构成功") + + +@router.get("/project/{project_id}/tables/{table_name}/data", response_model=UnifiedResponse[List[Dict[str, Any]]]) +async def get_table_data_by_project( + project_id: int, + table_name: str, + limit: int = 100, + offset: int = 0, + db: AsyncSession = Depends(deps.get_db), + current_user = Depends(deps.get_current_active_user), +): + """ + 通过项目 ID 获取指定表的数据(无需创建会话)。 + """ + instance = await get_db_instance_by_project(db, project_id, current_user.user_id) + + if not table_name.isidentifier(): + raise ValidationException("无效的表名") + + limit = min(limit, 1000) + + if instance.db_type == 'mysql': + sql = f"SELECT * FROM `{table_name}` LIMIT {limit} OFFSET {offset}" + result = await execute_mysql(sql, "SELECT", instance) + + elif instance.db_type == 'postgresql': + sql = f'SELECT * FROM "{table_name}" LIMIT {limit} OFFSET {offset}' + result = await execute_postgres(sql, "SELECT", instance) + + elif instance.db_type == 'sqlite': + sql = f'SELECT * FROM "{table_name}" LIMIT {limit} OFFSET {offset}' + result = await execute_sqlite(sql, "SELECT", instance, current_user.user_id) + + else: + return UnifiedResponse.success(data=[], message="获取表数据成功") + + return UnifiedResponse.success(data=result or [], message="获取表数据成功") diff --git a/src/backend/app/api/v1/endpoints/knowledge.py b/src/backend/app/api/v1/endpoints/knowledge.py new file mode 100644 index 0000000..a25ceb5 --- /dev/null +++ b/src/backend/app/api/v1/endpoints/knowledge.py @@ -0,0 +1,174 @@ +""" +知识库/术语 API 端点。 + +管理项目的业务术语,包括增删改查、批量导入导出等操作。 +""" + +from fastapi import APIRouter, Depends, Query, UploadFile, File, Path +from sqlalchemy.ext.asyncio import AsyncSession as Session +from typing import Any, Optional, List + +# 隐式绝对导入 +from api.v1 import deps +from service import knowledge_service +from schema import knowledge as schemas +from schema.unified_response import UnifiedResponse, PageData +from core.exceptions import ItemNotFoundException, ValidationException + +# 注意:这个 Router 稍后需要在 api.py 中注册,且不带 prefix,因为路径包含 {project_id} +router = APIRouter() + +# ---------------------------------------------------------------------- +# 3.4.1. 创建术语 +# ---------------------------------------------------------------------- +@router.post("/projects/{project_id}/knowledge", response_model=UnifiedResponse[schemas.KnowledgeResponse]) +async def create_term( + data: schemas.KnowledgeCreate, + project_id: int = Path(..., description="项目ID"), + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 创建新业务术语。 + + Args: + data (schemas.KnowledgeCreate): 术语创建请求体。 + project_id (int): 项目 ID。 + db (Session): 数据库会话。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.KnowledgeResponse]: 创建后的术语信息。 + """ + result = await knowledge_service.create_knowledge_service(db, project_id, current_user.user_id, data) + return UnifiedResponse.success(data=result, message="创建业务术语成功") + + +# ---------------------------------------------------------------------- +# 3.4.2. 获取术语列表 +# ---------------------------------------------------------------------- +@router.get("/projects/{project_id}/knowledge", response_model=UnifiedResponse[PageData[List[schemas.KnowledgeResponse]]]) +async def get_terms( + project_id: int = Path(..., description="项目ID"), + search: Optional[str] = Query(None, description="按术语名称搜索"), + page: int = Query(1, ge=1), + page_size: int = Query(20, le=100), + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 获取项目中的所有术语。 + + Args: + project_id (int): 项目 ID。 + search (Optional[str]): 搜索关键字。 + page (int): 页码。 + page_size (int): 每页数量。 + db (Session): 数据库会话。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[PageData[List[schemas.KnowledgeResponse]]]: 分页术语列表。 + """ + result = await knowledge_service.get_knowledge_list_service( + db, project_id, current_user.user_id, page, page_size, search + ) + page_data = PageData( + total=result.total, + page=result.page, + page_size=result.page_size, + items=result.items + ) + return UnifiedResponse.success(data=page_data, message="获取术语列表成功") + + +# ---------------------------------------------------------------------- +# [新增] 更新术语 +# ---------------------------------------------------------------------- +@router.patch("/projects/{project_id}/knowledge/{knowledge_id}", response_model=UnifiedResponse[schemas.KnowledgeResponse]) +async def update_term( + data: schemas.KnowledgeUpdate, + project_id: int = Path(..., description="项目ID"), + knowledge_id: int = Path(..., description="术语ID"), + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 更新术语信息。 + + Args: + data (schemas.KnowledgeUpdate): 术语更新请求体。 + project_id (int): 项目 ID。 + knowledge_id (int): 术语 ID。 + db (Session): 数据库会话。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.KnowledgeResponse]: 更新后的术语信息。 + """ + result = await knowledge_service.update_knowledge_service( + db, project_id, knowledge_id, current_user.user_id, data + ) + return UnifiedResponse.success(data=result, message="更新术语成功") + +# ---------------------------------------------------------------------- +# [新增] 批量删除术语 +# ---------------------------------------------------------------------- +@router.delete("/projects/{project_id}/knowledge/batch", response_model=UnifiedResponse[schemas.ImportResponse]) +async def batch_delete_terms( + data: schemas.BulkDeleteRequest, + project_id: int = Path(..., description="项目ID"), + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 批量删除术语。 + + Args: + data (schemas.BulkDeleteRequest): 批量删除请求体。 + project_id (int): 项目 ID。 + db (Session): 数据库会话。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.ImportResponse]: 删除结果。 + """ + count = await knowledge_service.batch_delete_knowledge_service( + db, project_id, current_user.user_id, data.ids + ) + delete_result = schemas.ImportResponse( + imported_count=0, + failed_count=0, + failures=[], + message=f"成功删除 {count} 项" + ) + return UnifiedResponse.success(data=delete_result, message="批量删除术语成功") + + + +# ---------------------------------------------------------------------- +# 3.4.3. 批量导入术语 +# ---------------------------------------------------------------------- +@router.post("/projects/{project_id}/knowledge/import", response_model=UnifiedResponse[schemas.ImportResponse]) +async def import_terms( + project_id: int = Path(..., description="项目ID"), + file: UploadFile = File(..., description="CSV 文件"), + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 从文件批量导入术语。 + + Args: + project_id (int): 项目 ID。 + file (UploadFile): 上传的文件。 + db (Session): 数据库会话。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.ImportResponse]: 导入结果统计。 + """ + result = await knowledge_service.import_knowledge_service( + db, project_id, current_user.user_id, file + ) + return UnifiedResponse.success(data=result, message="批量导入术语成功") \ No newline at end of file diff --git a/src/backend/app/api/v1/endpoints/message.py b/src/backend/app/api/v1/endpoints/message.py new file mode 100644 index 0000000..7bf1437 --- /dev/null +++ b/src/backend/app/api/v1/endpoints/message.py @@ -0,0 +1,40 @@ +""" +消息 API 端点。 + +处理会话消息历史的获取。 +""" +from typing import Any, List +from fastapi import APIRouter, Depends, Query, Path +from sqlalchemy.ext.asyncio import AsyncSession + +from api.v1 import deps +# 导入你现有的 crud 对象 +from crud.crud_message import crud_message +# 导入 Schema,用于数据验证和返回格式化 +from schema.message import MessageResponse +from schema.unified_response import UnifiedResponse + +router = APIRouter() + +# 获取特定会话的历史消息 +@router.get("/", response_model=UnifiedResponse[List[MessageResponse]], summary="获取会话消息历史") +async def read_messages( + db: AsyncSession = Depends(deps.get_db), + session_id: int = Query(..., description="会话ID"), + limit: int = 50, # 你的 CRUD 支持 limit +) -> Any: + """ + 根据 session_id 获取该会话的消息历史记录。 + + Args: + db (AsyncSession): 数据库会话。 + session_id (int): 会话ID。 + limit (int): 限制返回的消息数量。 + + Returns: + UnifiedResponse[List[MessageResponse]]: 消息历史列表响应。 + """ + messages = await crud_message.get_recent_messages( + db=db, session_id=session_id, limit=limit + ) + return UnifiedResponse.success(data=messages, message="获取消息历史成功") diff --git a/src/backend/app/api/v1/endpoints/project.py b/src/backend/app/api/v1/endpoints/project.py new file mode 100644 index 0000000..e4c4fd6 --- /dev/null +++ b/src/backend/app/api/v1/endpoints/project.py @@ -0,0 +1,279 @@ +""" +项目 API 端点。 + +管理项目的全生命周期,包括创建(生成Schema、DDL、部署)、查询、更新和删除。 +支持 Celery 异步任务状态查询。 +""" +from fastapi import APIRouter, Depends, Query, Header, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession as Session +from typing import Any, Optional, List + +# 隐式绝对导入 +from api.v1 import deps +from service import project_service +from schema import project as schemas +from schema.unified_response import UnifiedResponse, PageData +from core.exceptions import ItemNotFoundException, OperationNotPermittedException, ValidationException + +router = APIRouter() + + +# ========================================================= +# 任务状态查询接口 +# ========================================================= +@router.get("/tasks/{task_id}", response_model=UnifiedResponse[schemas.TaskStatusResponse]) +async def get_task_status( + task_id: str, + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 查询 Celery 异步任务状态。 + + Args: + task_id (str): Celery 任务 ID。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.TaskStatusResponse]: 任务状态响应。 + """ + result = await project_service.get_task_status_service(task_id) + return UnifiedResponse.success(data=result, message="获取任务状态成功") + + +# 1. 创建项目 (第一步:只生成 Schema) +@router.post("/", response_model=UnifiedResponse[schemas.ProjectAsyncResponse]) +async def create_project( + project_in: schemas.ProjectCreate, + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 创建项目第一步: + 1. 创建项目记录。 + 2. 触发 Celery 后台任务生成 Logical Schema 和 ER 图。 + 3. 返回项目ID和任务ID,用户可通过任务ID查询生成状态。 + + Args: + project_in (schemas.ProjectCreate): 项目创建请求体。 + db (Session): 数据库会话依赖。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.ProjectAsyncResponse]: 异步响应,包含项目ID和任务ID。 + """ + result = await project_service.create_project_service(db, project_in, current_user.user_id) + return UnifiedResponse.success(data=result, message="项目创建请求已提交") + + +# ========================================================= +# 接口:生成 DDL (第二步:用户确认 Schema 后调用) +# ========================================================= +@router.post("/{project_id}/generate-ddl", response_model=UnifiedResponse[schemas.ProjectAsyncResponse]) +async def generate_ddl( + project_id: int, + request_data: schemas.GenerateDDLRequest, + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 创建项目第二步: + 1. 接收用户确认/修改后的 Schema。 + 2. 触发 Celery 后台任务生成 DDL。 + 3. 如果 Schema 有修改,同时重新生成 ER 图。 + + Args: + project_id (int): 项目ID。 + request_data (schemas.GenerateDDLRequest): 生成DDL请求体。 + db (Session): 数据库会话依赖。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.ProjectAsyncResponse]: 异步响应,包含项目ID和任务ID。 + """ + result = await project_service.request_ddl_generation_service( + db, project_id, current_user.user_id, request_data + ) + return UnifiedResponse.success(data=result, message="DDL生成请求已提交") + +# ========================================================= +# 执行部署 (第三步:用户确认 DDL 后调用) +# ========================================================= +@router.post("/{project_id}/deploy", response_model=UnifiedResponse[schemas.ProjectDetailOut]) +async def deploy_project( + project_id: int, + deploy_data: schemas.ProjectDeployRequest, + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 创建项目第三步: + 1. 接收用户确认/修改后的 DDL。 + 2. 执行建库建表。 + 3. 项目状态变为 Active。 + + Args: + project_id (int): 项目ID。 + deploy_data (schemas.ProjectDeployRequest): 部署请求体。 + db (Session): 数据库会话依赖。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.ProjectDetailOut]: 部署后的项目信息。 + """ + result = await project_service.deploy_project_service( + db, project_id, current_user.user_id, deploy_data + ) + return UnifiedResponse.success(data=result, message="项目部署成功") + + +# 2. 获取列表 (分页) +@router.get("/", response_model=UnifiedResponse[PageData[List[schemas.ProjectListOne]]]) +async def read_projects( + search: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, le=100), + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 分页获取项目列表。 + + Args: + search (Optional[str]): 搜索关键字。 + page (int): 页码。 + page_size (int): 每页数量。 + db (Session): 数据库会话依赖。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[PageData[List[schemas.ProjectListOne]]]: 分页项目列表响应。 + """ + result = await project_service.get_projects_list_service(db, current_user.user_id, search, page, page_size) + page_data = PageData( + total=result.total, + page=result.page, + page_size=result.page_size, + items=result.items + ) + return UnifiedResponse.success(data=page_data, message="获取项目列表成功") + +# 3. 获取详情 +@router.get("/{project_id}", response_model=UnifiedResponse[schemas.ProjectDetailOut]) +async def read_project_detail( + project_id: int, + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 获取项目详情。 + + Args: + project_id (int): 项目 ID。 + db (Session): 数据库会话依赖。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.ProjectDetailOut]: 项目详情响应。 + """ + result = await project_service.get_project_detail_service(db, project_id, current_user.user_id) + return UnifiedResponse.success(data=result, message="获取项目详情成功") + +# 4. 更新项目 +@router.patch("/{project_id}", response_model=UnifiedResponse[schemas.ProjectDetailOut]) +async def update_project_info( + project_id: int, + update_data: schemas.ProjectUpdate, + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 更新项目信息。 + + Args: + project_id (int): 项目 ID。 + update_data (schemas.ProjectUpdate): 更新数据。 + db (Session): 数据库会话依赖。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.ProjectDetailOut]: 更新后的项目信息响应。 + """ + result = await project_service.update_project_info_service(db, project_id, current_user.user_id, update_data.model_dump(exclude_unset=True)) + return UnifiedResponse.success(data=result, message="项目信息更新成功") + +# 5. 确认删除 +@router.post("/{project_id}/confirm-delete", response_model=UnifiedResponse[schemas.ConfirmationTokenResponse]) +async def confirm_delete( + project_id: int, + data: schemas.DeleteConfirmationRequest, + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 确认删除项目,生成删除令牌。 + + Args: + project_id (int): 项目 ID。 + data (schemas.DeleteConfirmationRequest): 删除确认请求体。 + db (Session): 数据库会话依赖。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.ConfirmationTokenResponse]: 删除令牌响应。 + """ + result = await project_service.confirm_delete_project_service(db, project_id, current_user.user_id, data.confirmation_text) + return UnifiedResponse.success(data=result, message="删除确认成功,令牌已生成") + +# 6. 最终删除 +@router.delete("/{project_id}", response_model=UnifiedResponse[None]) +async def delete_project( + project_id: int, + x_confirmation_token: str = Header(..., alias="X-Confirmation-Token"), + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 最终删除项目。 + + Args: + project_id (int): 项目 ID。 + x_confirmation_token (str): 删除令牌。 + db (Session): 数据库会话依赖。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[None]: 删除成功响应。 + """ + await project_service.delete_project_service(db, project_id, current_user.user_id, x_confirmation_token) + return UnifiedResponse.success(data=None, message="项目删除成功") + + +# ========================================================= +# 重新生成 ER 图 +# ========================================================= +@router.post("/{project_id}/regenerate-er", response_model=UnifiedResponse[schemas.ProjectAsyncResponse]) +async def regenerate_er_diagram( + project_id: int, + request_data: schemas.RegenerateERRequest, + db: Session = Depends(deps.get_db), + current_user: Any = Depends(deps.get_current_active_user), +) -> Any: + """ + 重新生成项目的 ER 图。 + + 当用户修改 Schema 后,可以调用此接口异步重新生成 ER 图。 + + Args: + project_id (int): 项目 ID。 + request_data (schemas.RegenerateERRequest): 重新生成 ER 图请求体。 + db (Session): 数据库会话依赖。 + current_user (Any): 当前登录用户。 + + Returns: + UnifiedResponse[schemas.ProjectAsyncResponse]: 异步响应,包含任务ID。 + """ + result = await project_service.regenerate_project_er_service( + db, project_id, current_user.user_id, + request_data.schema_text, request_data.ai_model + ) + return UnifiedResponse.success(data=result, message="ER图重新生成请求已提交") diff --git a/src/backend/app/api/v1/endpoints/reports.py b/src/backend/app/api/v1/endpoints/reports.py new file mode 100644 index 0000000..8b0b64c --- /dev/null +++ b/src/backend/app/api/v1/endpoints/reports.py @@ -0,0 +1,208 @@ +""" +报表 API 端点。 + +提供报表的增删改查、导出及历史查询记录获取功能。 +""" +from fastapi import APIRouter, Depends, Query, Path +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List + +from schema.report import ReportCreate, Report, HistoryQuery, ReportUpdate +from schema.user import UserMe +from api.v1.deps import get_db, get_current_active_user +from service import report_service +from schema.unified_response import UnifiedResponse + +router = APIRouter() + +# ------------------------------------------- +# 1. 获取报表列表 (Read) +# ------------------------------------------- +@router.get("/reports", response_model=UnifiedResponse[List[Report]], summary="获取报表列表") +async def read_reports( + project_id: int = Query(..., description="项目ID"), + db: AsyncSession = Depends(get_db), + user: UserMe = Depends(get_current_active_user), +): + """ + 获取指定项目下的所有保存的报表配置(仅限当前用户的项目)。 + + Args: + project_id (int): 项目ID。 + db (AsyncSession): 数据库会话。 + user (UserMe): 当前登录用户。 + + Returns: + UnifiedResponse[List[Report]]: 报表列表响应。 + """ + result = await report_service.get_report_list( + db=db, + project_id=project_id, + user_id=user.user_id + ) + return UnifiedResponse.success(data=result, message="获取报表列表成功") + + +# ------------------------------------------- +# 2. 获取历史查询记录 +# ------------------------------------------- +@router.get("/history-queries", response_model=UnifiedResponse[List[HistoryQuery]], summary="获取历史查询记录") +async def read_history( + project_id: int = Query(..., description="项目ID"), + db: AsyncSession = Depends(get_db), + user: UserMe = Depends(get_current_active_user), +): + """ + 获取历史查询结果,用于作为创建新报表的数据源(仅限当前用户的项目)。 + + Args: + project_id (int): 项目ID。 + db (AsyncSession): 数据库会话。 + user (UserMe): 当前登录用户。 + + Returns: + UnifiedResponse[List[HistoryQuery]]: 历史查询记录列表响应。 + """ + result = await report_service.get_history_queries_service( + db=db, + project_id=project_id, + user_id=user.user_id + ) + return UnifiedResponse.success(data=result, message="获取历史查询记录成功") + + +# ------------------------------------------- +# 3. 创建报表 (Create) +# ------------------------------------------- +@router.post( + "/projects/{project_id}/reports", + response_model=UnifiedResponse[Report], + summary="创建报表" +) +async def create_report( + body: ReportCreate, + project_id: int = Path(..., description="项目ID"), + db: AsyncSession = Depends(get_db), + user: UserMe = Depends(get_current_active_user), +): + """ + 在指定项目下创建报表(仅限当前用户的项目)。 + + Args: + body (ReportCreate): 报表创建请求体。 + project_id (int): 项目ID。 + db (AsyncSession): 数据库会话。 + user (UserMe): 当前登录用户。 + + Returns: + UnifiedResponse[Report]: 创建后的报表信息响应。 + """ + result = await report_service.create_report_service( + db=db, + project_id=project_id, + payload=body, + user_id=user.user_id + ) + return UnifiedResponse.success(data=result, message="报表创建成功") + + +# ------------------------------------------- +# 4. 删除报表 (Delete) +# ------------------------------------------- +@router.delete( + "/reports/{report_id}", + response_model=UnifiedResponse[bool], + summary="删除报表" +) +async def delete_report( + report_id: int = Path(..., description="报表ID"), + db: AsyncSession = Depends(get_db), + user: UserMe = Depends(get_current_active_user), +): + """ + 删除指定报表(仅限当前用户)。 + + Args: + report_id (int): 报表ID。 + db (AsyncSession): 数据库会话。 + user (UserMe): 当前登录用户。 + + Returns: + UnifiedResponse[bool]: 删除结果响应。 + """ + result = await report_service.delete_report_service( + db=db, + report_id=report_id, + user_id=user.user_id + ) + return UnifiedResponse.success(data=result, message="报表删除成功") + + +# ------------------------------------------- +# 5. 修改报表信息 (Update) +# ------------------------------------------- +@router.put( + "/reports/{report_id}", + response_model=UnifiedResponse[Report], + summary="更新报表" +) +async def update_report( + body: ReportUpdate, + report_id: int = Path(..., description="报表ID"), + db: AsyncSession = Depends(get_db), + user: UserMe = Depends(get_current_active_user), +): + """ + 修改报表配置(仅限当前用户)。 + + Args: + body (ReportUpdate): 报表更新请求体。 + report_id (int): 报表ID。 + db (AsyncSession): 数据库会话。 + user (UserMe): 当前登录用户。 + + Returns: + UnifiedResponse[Report]: 更新后的报表信息响应。 + """ + result = await report_service.update_report_service( + db=db, + report_id=report_id, + payload=body, + user_id=user.user_id + ) + return UnifiedResponse.success(data=result, message="报表更新成功") + + +# ------------------------------------------- +# 6. 导出报表 (Export) +# ------------------------------------------- +@router.get( + "/reports/{report_id}/export", + response_model=UnifiedResponse[dict], + summary="导出报表" +) +async def export_report( + report_id: int = Path(..., description="报表ID"), + format: str = Query("png", regex="^(png|jpeg|pdf)$"), + db: AsyncSession = Depends(get_db), + user: UserMe = Depends(get_current_active_user), +): + """ + 导出报表(仅限当前用户)。 + + Args: + report_id (int): 报表ID。 + format (str): 导出格式(png, jpeg, pdf)。 + db (AsyncSession): 数据库会话。 + user (UserMe): 当前登录用户。 + + Returns: + UnifiedResponse[dict]: 包含导出文件信息的字典响应。 + """ + result = await report_service.export_report_service( + db=db, + report_id=report_id, + format=format, + user_id=user.user_id + ) + return UnifiedResponse.success(data=result, message="报表导出成功") diff --git a/src/backend/app/api/v1/endpoints/session.py b/src/backend/app/api/v1/endpoints/session.py new file mode 100644 index 0000000..752bd8e --- /dev/null +++ b/src/backend/app/api/v1/endpoints/session.py @@ -0,0 +1,169 @@ +""" +会话 API 端点。 + +管理用户会话,包括会话的创建、查询、更新和删除。 +""" +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query, Path, Body +from sqlalchemy.ext.asyncio import AsyncSession + +from api.v1 import deps +from service.session_service import session_service +from schema.session import SessionCreate, SessionUpdate, SessionResponse +from schema.user import UserMe +from schema.unified_response import UnifiedResponse, PageData +from core.log import log + +router = APIRouter() + + +@router.get("/", response_model=UnifiedResponse[PageData[List[SessionResponse]]], summary="获取会话列表") +async def read_sessions( + db: AsyncSession = Depends(deps.get_db), + current_user: UserMe = Depends(deps.get_current_active_user), + project_id: Optional[int] = Query(None, description="筛选指定项目的会话"), + skip: int = 0, + limit: int = 100, +): + """ + 获取当前用户的会话列表,支持按项目筛选。 + + Args: + db (AsyncSession): 数据库会话。 + current_user (UserMe): 当前登录用户。 + project_id (Optional[int]): 筛选指定项目的会话。 + skip (int): 跳过记录数。 + limit (int): 返回记录数限制。 + + Returns: + UnifiedResponse[PageData[List[SessionResponse]]]: 分页会话列表响应。 + """ + sessions = await session_service.get_sessions( + db=db, + user=current_user, + project_id=project_id, + skip=skip, + limit=limit + ) + + # 获取总数 + total = await session_service.get_sessions_count( + db=db, + user=current_user, + project_id=project_id + ) + + # 构建分页数据 + page_data = PageData( + total=total, + page=(skip // limit) + 1, + page_size=limit, + items=sessions + ) + + return UnifiedResponse.success(data=page_data, message="获取会话列表成功") + + +@router.post("/", response_model=UnifiedResponse[SessionResponse], summary="创建新会话") +async def create_session( + session_in: SessionCreate, + db: AsyncSession = Depends(deps.get_db), + current_user: UserMe = Depends(deps.get_current_active_user), +): + """ + 在指定项目下创建新的会话。 + + Args: + db (AsyncSession): 数据库会话。 + current_user (UserMe): 当前登录用户。 + session_in (SessionCreate): 会话创建请求体。 + + Returns: + UnifiedResponse[SessionResponse]: 创建后的会话信息响应。 + """ + log.info("创建会话") + result = await session_service.create_session( + db=db, + user=current_user, + session_in=session_in + ) + return UnifiedResponse.success(data=result, message="会话创建成功") + + +@router.get("/{session_id}", response_model=UnifiedResponse[SessionResponse], summary="获取会话详情") +async def read_session( + db: AsyncSession = Depends(deps.get_db), + current_user: UserMe = Depends(deps.get_current_active_user), + session_id: int = Path(..., description="会话ID"), +): + """ + 获取单个会话详情,仅允许访问自身项目下的会话。 + + Args: + db (AsyncSession): 数据库会话。 + current_user (UserMe): 当前登录用户。 + session_id (int): 会话ID。 + + Returns: + UnifiedResponse[SessionResponse]: 会话详情响应。 + """ + result = await session_service.get_session( + db=db, + user=current_user, + session_id=session_id + ) + return UnifiedResponse.success(data=result, message="获取会话详情成功") + + +@router.put("/{session_id}", response_model=UnifiedResponse[SessionResponse], summary="更新会话信息") +async def update_session( + db: AsyncSession = Depends(deps.get_db), + current_user: UserMe = Depends(deps.get_current_active_user), + session_id: int = Path(..., description="会话ID"), + session_in: SessionUpdate = Body(...), +): + """ + 更新会话信息(如重命名)。 + + Args: + db (AsyncSession): 数据库会话。 + current_user (UserMe): 当前登录用户。 + session_id (int): 会话ID。 + session_in (SessionUpdate): 会话更新请求体。 + + Returns: + UnifiedResponse[SessionResponse]: 更新后的会话信息响应。 + """ + result = await session_service.update_session( + db=db, + user=current_user, + session_id=session_id, + session_in=session_in + ) + return UnifiedResponse.success(data=result, message="会话更新成功") + + +@router.delete("/{session_id}", response_model=UnifiedResponse[bool], summary="删除会话") +async def delete_session( + db: AsyncSession = Depends(deps.get_db), + current_user: UserMe = Depends(deps.get_current_active_user), + session_id: int = Path(..., description="会话ID"), +): + """ + 删除会话,仅允许删除自身项目下的会话。 + + Args: + db (AsyncSession): 数据库会话。 + current_user (UserMe): 当前登录用户。 + session_id (int): 会话ID。 + + Returns: + UnifiedResponse[bool]: 删除结果响应。 + """ + result = await session_service.delete_session( + db=db, + user=current_user, + session_id=session_id + ) + return UnifiedResponse.success(data=result, message="会话删除成功") diff --git a/src/backend/app/api/v1/endpoints/user.py b/src/backend/app/api/v1/endpoints/user.py new file mode 100644 index 0000000..23cd81d --- /dev/null +++ b/src/backend/app/api/v1/endpoints/user.py @@ -0,0 +1,234 @@ +""" +用户 API 端点。 + +处理当前用户的个人信息查询、修改(用户名、密码、头像、邮箱)及登录历史查询。 +""" + +# backend/app/api/v1/endpoints/user.py + +from fastapi import APIRouter, Depends, status, Body, File, UploadFile, Query +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Any +from .. import deps +# 隐式绝对导入 +from api.v1.deps import get_db, get_current_active_user +from schema import user as schemas # 导入 User Schema (UserMe, UserUpdatePassword, etc.) +from schema.unified_response import UnifiedResponse, PageData +from service import user_service # 导入 Service 层 +# 导入所有必要的业务异常 +from core.exceptions import ItemNotFoundException, ValidationException, PasswordInvalidException, CodeInvalidException, UserAlreadyExistsException, UserNotFoundException + + + +router = APIRouter() + +# ---------------------------------------------------------------------- +# 1. GET /users/me - 获取当前用户信息 (Doc 3.1.1) +# ---------------------------------------------------------------------- +@router.get("/me", response_model=UnifiedResponse[schemas.UserMe]) +async def read_user_me( + db: AsyncSession = Depends(get_db), + current_user = Depends(get_current_active_user), +) -> Any: + """ + 获取当前登录用户详细信息。 + + Args: + db (AsyncSession): 数据库会话。 + current_user (User): 当前登录用户。 + + Returns: + UnifiedResponse[UserMe]: 用户信息。 + + Raises: + HTTPException: 用户未找到(404)或内部服务器错误(500)。 + """ + # Service 层负责将 ORM 转换为 UserMe DTO + result = await user_service.get_user_me_service(db, current_user.user_id) + return UnifiedResponse.success(data=result, message="获取用户信息成功") + + +# ---------------------------------------------------------------------- +# 2. PATCH /users/me - 更新用户名 (Doc 3.1.2) +# ---------------------------------------------------------------------- +@router.patch("/me", response_model=UnifiedResponse[schemas.UserMe]) +async def update_username_endpoint( + username_data: schemas.UserUpdateUsername, + db: AsyncSession = Depends(get_db), + current_user = Depends(get_current_active_user), +) -> Any: + """ + 更新当前用户名。 + + Args: + username_data (schemas.UserUpdateUsername): 用户名更新请求体。 + db (AsyncSession): 数据库会话。 + current_user (User): 当前登录用户。 + + Returns: + UnifiedResponse[UserMe]: 更新后的用户信息。 + + Raises: + HTTPException: 用户名已存在(409)、验证失败(422)或内部服务器错误(500)。 + """ + updated_user_dto = await user_service.update_username_service( + db, + current_user.user_id, + username_data + ) + # 成功返回 200 OK,返回更新后的 DTO + return UnifiedResponse.success(data=updated_user_dto, message="用户名更新成功") + + +# ---------------------------------------------------------------------- +# 3. PUT /users/me/password - 修改密码 (Doc 3.1.3) +# ---------------------------------------------------------------------- +@router.put("/me/password", response_model=UnifiedResponse[None]) +async def update_password_endpoint( + password_data: schemas.UserUpdatePassword, + db: AsyncSession = Depends(get_db), + current_user = Depends(get_current_active_user), +) -> None: + """ + 更新当前用户密码。 + + Args: + password_data (schemas.UserUpdatePassword): 密码更新请求体。 + db (AsyncSession): 数据库会话。 + current_user (User): 当前登录用户。 + + Returns: + UnifiedResponse[None]: 密码更新结果。 + + Raises: + HTTPException: 密码错误(401)或内部服务器错误(500)。 + """ + await user_service.update_password_service( + db, + current_user.user_id, + password_data + ) + # 成功返回统一响应 + return UnifiedResponse.success(message="密码更新成功") + + +# ---------------------------------------------------------------------- +# 4. POST /users/me/avatar - 上传/更换头像 (Doc 3.1.6) +# ---------------------------------------------------------------------- +@router.post("/me/avatar", response_model=UnifiedResponse[schemas.UserUpdateAvatar]) +async def update_avatar_endpoint( + # 错误代码 (当前): file: UploadFile = File(...) + # 修正代码: 改回接收 JSON Body + avatar_data: schemas.UserUpdateAvatar, + db: AsyncSession = Depends(get_db), + current_user = Depends(get_current_active_user), +) -> Any: + """ + 更新头像 URL (接收 JSON)。 + + Args: + avatar_data (schemas.UserUpdateAvatar): 头像更新请求体。 + db (AsyncSession): 数据库会话。 + current_user (User): 当前登录用户。 + + Returns: + UnifiedResponse[UserUpdateAvatar]: 更新后的头像信息。 + + Raises: + HTTPException: 内部服务器错误(500)。 + """ + # Service 期望接收的是 schemas.UserUpdateAvatar 对象 + updated_avatar_dto = await user_service.update_avatar_service( + db, + current_user.user_id, + avatar_data # 传递 Pydantic Schema + ) + return UnifiedResponse.success(data=updated_avatar_dto, message="头像更新成功") + +# ---------------------------------------------------------------------- +# 5. POST /users/me/email/send-code - 请求更新邮箱 (Doc 3.1.4) +# ---------------------------------------------------------------------- +@router.post("/me/email/send-code", response_model=UnifiedResponse[None]) +async def request_update_email_endpoint( + request_data: schemas.UserUpdateEmailRequest, + db: AsyncSession = Depends(get_db), + current_user = Depends(get_current_active_user), +) -> None: + """ + 向新邮箱发送验证码。 + + Args: + request_data (schemas.UserUpdateEmailRequest): 邮箱更新请求体。 + db (AsyncSession): 数据库会话。 + current_user (User): 当前登录用户。 + + Returns: + UnifiedResponse[None]: 发送验证码结果。 + """ + await user_service.request_update_email_service(db, current_user.user_id, request_data) + return UnifiedResponse.success(message="验证码发送成功") + + +# ---------------------------------------------------------------------- +# 6. PUT /users/me/email - 更新邮箱确认 (Doc 3.1.5) +# ---------------------------------------------------------------------- +@router.put("/me/email", response_model=UnifiedResponse[schemas.UserMe]) +async def confirm_update_email_endpoint( + confirm_data: schemas.UserUpdateEmailConfirm, + db: AsyncSession = Depends(get_db), + current_user = Depends(get_current_active_user), +) -> Any: + """ + 确认邮箱变更 (验证验证码)。 + + Args: + confirm_data (schemas.UserUpdateEmailConfirm): 邮箱更新确认请求体。 + db (AsyncSession): 数据库会话。 + current_user (User): 当前登录用户。 + + Returns: + UnifiedResponse[UserMe]: 更新后的用户信息。 + """ + updated_user_dto = await user_service.confirm_update_email_service( + db, + current_user.user_id, + confirm_data + ) + return UnifiedResponse.success(data=updated_user_dto, message="邮箱更新成功") + + +@router.get("/me/login-history", response_model=UnifiedResponse[PageData[list]]) +async def read_login_history( + db: AsyncSession = Depends(deps.get_db), + current_user=Depends(deps.get_current_active_user), + # 接收标准分页参数 + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(10, ge=1, le=100, description="每页数量"), # Doc 要求默认 10 +) -> Any: + """ + 获取当前用户的登录历史记录 (包含分页)。 + + Args: + db (AsyncSession): 数据库会话。 + current_user (User): 当前登录用户。 + page (int): 页码。 + page_size (int): 每页数量。 + + Returns: + UnifiedResponse[PageData[list]]: 分页登录历史记录。 + """ + # Router 严格只调用 Service + history_list = await user_service.get_login_history_service( + db, + user_id=current_user.user_id, + page=page, + page_size=page_size + ) + # 将原有分页结构转换为新的 PageData 结构 + page_data = PageData( + total=history_list.total, + page=history_list.page, + page_size=history_list.page_size, + items=history_list.items + ) + return UnifiedResponse.success(data=page_data, message="获取登录历史成功") diff --git a/src/backend/app/celery_app.py b/src/backend/app/celery_app.py new file mode 100644 index 0000000..a0d8089 --- /dev/null +++ b/src/backend/app/celery_app.py @@ -0,0 +1,46 @@ +# backend/app/celery_app.py + +""" +Celery 应用配置模块 + +负责配置和初始化 Celery 异步任务队列 +""" + +import os +from celery import Celery + +# 从环境变量获取 Redis 配置,提供默认值 +REDIS_HOST = os.getenv("REDIS__HOST", "localhost") +REDIS_PORT = os.getenv("REDIS__PORT", "6379") + +# Celery Broker 和 Backend URL +CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", f"redis://{REDIS_HOST}:{REDIS_PORT}/0") +CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", f"redis://{REDIS_HOST}:{REDIS_PORT}/0") + +# 创建 Celery 应用实例 +celery_app = Celery( + "app", + broker=CELERY_BROKER_URL, + backend=CELERY_RESULT_BACKEND, + include=["tasks.ai_generation_tasks"] # 包含 AI 生成任务模块 +) + +# Celery 配置 +celery_app.conf.update( + # 任务序列化方式 + task_serializer="json", + # 结果序列化方式 + result_serializer="json", + # 接受的内容类型 + accept_content=["json"], + # 时区设置 + timezone="Asia/Shanghai", + # 启用 UTC + enable_utc=True, + # 任务结果过期时间 (秒) + result_expires=3600, + # 任务确认方式:任务完成后确认 + task_acks_late=True, + # Worker 预取任务数量 + worker_prefetch_multiplier=1, +) diff --git a/src/backend/app/config/__init__.py b/src/backend/app/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/app/config/base.py b/src/backend/app/config/base.py new file mode 100644 index 0000000..76cc6e0 --- /dev/null +++ b/src/backend/app/config/base.py @@ -0,0 +1,336 @@ +# backend/app/config/base.py + +from typing import List, Optional + +from pydantic_settings import BaseSettings +from pydantic import SecretStr, BaseModel +from sqlalchemy import URL + +class Appconfig(BaseSettings): + """ + FastAPI的应用配置 + """ + # 应用名称 + name: str + # 应用描述 + description: str + # 应用接口 + api: str + # 应用主机地址 + host: str + # 应用端口 + port: int + # uvicorn应用入口 + uvicorn: str + # 应用版本 + version: str + # 是否自动重载 + reload: bool + +class DatabaseConfig(BaseSettings): + """ + PostgresSQL数据库配置 + """ + + # 数据库主机地址 + host: str + # 数据库端口 + port: int + # 数据库用户名 + username: str + # 数据库密码 + password: SecretStr + # 数据库名称 + database: str + # 数据库连接配置 + driver: str + # 是否开启sqlalchemy日志 + echo: bool + # 允许溢出连接池大小的最大连接数 + max_overflow: int + # 连接池大小 + pool_size: int + # 池回收连接的时间间隔 + pool_recycle: int + # 连接池中没有线程可用时,最多等待的时间 + pool_timeout: int + + @property + def sqlalchemy_database_url(self) -> URL: + return URL.create( + drivername=self.driver, + username=self.username, + password=self.password.get_secret_value(), + host=self.host, + port=self.port, + database=self.database, + ) + + +class RedisConfig(BaseSettings): + """ + Redis数据库配置 + """ + + # Redis主机地址 + host: str + # Redis端口 + port: int + # Redis数据库索引 + db: int + # Redis密码 + password: Optional[str] = None + # 连接超时时间(秒) + connect_timeout: float = 5.0 + # 读取超时时间(秒) + read_timeout: float = 3.0 + # 写入超时时间(秒) + write_timeout: float = 3.0 + # 连接池最大连接数(建议根据实际并发量调整,生产环境通常设置为50-200) + max_connections: int = 50 + # 订阅频道 + subscribe_channel: str + # 键前缀(用于多环境隔离) + key_prefix: str = "" + # 哨兵模式配置 + sentinel_enabled: bool = False # 是否启用哨兵模式 + sentinels: List[str] = [] # 哨兵节点列表,格式: ["host1:port1", "host2:port2"] + master_name: str = "mymaster" # 主节点名称 + # SSL配置 + ssl: bool = False # 是否启用SSL + ssl_cert_reqs: str = "required" # SSL证书验证要求 + # 连接重试配置 + max_retries: int = 3 # 连接重试次数 + retry_interval: float = 0.5 # 重试间隔(秒) + # 健康检查配置 + health_check_interval: int = 30 # 健康检查间隔(秒) + +class LogConfig(BaseSettings): + """ + 日志配置 + """ + # 日志文件级别 + file_level: str + # 日志控制台级别 + console_level: str + # 日志保存时间 + retention: str + # 日志轮转时间 + rotation: str + +class JWTConfig(BaseSettings): + """ + JWT配置 + """ + # JWT密钥 + secret_key: str + # JWT算法 + algorithm: str + # JWT过期时间 + token_expire_time_seconds: int + +class SMTP(BaseSettings): + """ + SMTP配置 + """ + # SMTP服务器 + host: str + # SMTP端口 + port: int + # 发送者名称 + sender_name: str + # 发送者邮箱 + sender: str + # 授权码 + key: str + # 验证码有效期 + expire_time_seconds: int + +class AIConfig(BaseSettings): + modelscope_api_key: str = "dummy_key" + mermaid_api_key: str = "" + + +class ChromaConfig(BaseSettings): + """ + ChromaDB 向量数据库配置 + """ + # ChromaDB 服务主机(Docker 模式下为服务名,本地开发为 localhost) + host: str = "localhost" + # ChromaDB 服务端口 + port: int = 8100 + # 是否使用 HTTP 客户端模式(True: 连接远程服务,False: 内嵌模式) + use_http_client: bool = True + # 内嵌模式下的持久化目录(相对于项目根目录) + persist_directory: str = "data/chroma_db" + + +class EmbeddingConfig(BaseSettings): + """ + Embedding 服务配置(阿里云百炼) + """ + # API Key(在 config.yaml 中配置,本地开发可使用环境变量 EMBEDDING_API_KEY) + api_key: str = "" + base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1" + model: str = "text-embedding-v4" + dimensions: int = 1024 + + +class MySQLConfig(BaseSettings): + """ + MySQL数据库配置 + """ + # 数据库主机 + host: str + # 数据库端口 + port: int + # 用户名 + username: str + # 密码 + password: SecretStr + # 数据库连接驱动 + driver: str + # SSL + ssl: bool + # plugin + plugin: str + # sqlalchemy连接池配置 + max_overflow: int + # sqlalchemy连接池大小 + pool_size: int + # sqlalchemy连接池回收时间 + pool_recycle: int + # sqlalchemy连接池空闲时间 + pool_timeout: int + + @property + def sqlalchemy_database_url(self) -> URL: + return URL.create( + drivername=self.driver, + username=self.username, + password=self.password.get_secret_value(), + host=self.host, + port=self.port + ) + +class PostgresConfig(BaseSettings): + # 数据库主机 + host: str + # 端口 + port: int + # 用户名 + username: str + # 密码 + password: str + # 数据库连接驱动 + driver: str + # 显示执行SQL + echo: bool + # sqlalchemy连接池配置 + max_overflow: int + pool_size: int + pool_recycle: int + pool_timeout: int + + @property + def sqlalchemy_database_url(self) -> URL: + return URL.create( + drivername=self.driver, + username=self.username, + password=self.password, + host=self.host, + port=self.port + ) + + +class SQLiteConfig(BaseSettings): + # 数据库驱动 + driver: str + # 数据库文件基础路径 + db_path: str + # 显示执行SQL + echo: bool + # sqlalchemy连接池配置 + max_overflow: int + pool_size: int + pool_recycle: int + pool_timeout: int + + @property + def sqlalchemy_database_url(self) -> str: + # SQLite使用文件路径,不需要用户名密码等 + return f"{self.driver}:///{self.db_path}/{{db_name}}.db" + + def get_database_path(self, db_name: str, user_id: Optional[int] = None) -> str: + """获取数据库文件的完整路径,支持用户隔离 + + Args: + db_name: 数据库名称 + user_id: 用户ID,用于隔离不同用户的数据库文件 + + Returns: + 数据库文件的完整路径 + """ + import os + from pathlib import Path + + if user_id is not None: + # 为不同用户创建独立的目录 + user_dir = Path(self.db_path) / f"user_{user_id}" + user_dir.mkdir(parents=True, exist_ok=True) + return str(user_dir / f"{db_name}.db") + else: + # 默认路径(兼容旧代码) + return str(Path(self.db_path) / f"{db_name}.db") + +class UserSQLPermissions(BaseModel): + allowed_operations: List[str] + forbidden_operations: List[str] + +class SQLPermissions(BaseSettings): + """ + SQL权限配置 + """ + root: UserSQLPermissions + normal: UserSQLPermissions + + +class BaseConfig(BaseSettings): + """ + 基础配置 + """ + + # 应用配置 + app: Appconfig + # ai 配置! + ai: AIConfig = AIConfig() + # PostgresSQL数据库配置 + db: DatabaseConfig + # Redis配置 + redis: RedisConfig + # 日志配置 + log: LogConfig + # JWT配置 + jwt: JWTConfig + # SMTP配置 + smtp: SMTP + # ChromaDB 向量数据库配置 + chroma: ChromaConfig = ChromaConfig() + # Embedding 服务配置 + embedding: EmbeddingConfig = EmbeddingConfig() + + # 前端基础地址(用于拼接邮件内的密码重置链接) + frontend_base_url: str = "http://localhost:5173/" + + # 忘记密码/重置密码配置 + password_reset_token_expire_seconds: int = 900 + password_reset_request_limit_per_email_per_hour: int = 3 + password_reset_request_limit_per_ip_per_hour: int = 20 + # MySQL数据库配置 + mysql: MySQLConfig + # Postgres数据库配置 + postgresql: PostgresConfig + # SQLite数据库配置 + sqlite: SQLiteConfig + # SQL权限配置 + sql_permissions: SQLPermissions \ No newline at end of file diff --git a/src/backend/app/core/__init__.py b/src/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/app/core/auth.py b/src/backend/app/core/auth.py new file mode 100644 index 0000000..fb573cb --- /dev/null +++ b/src/backend/app/core/auth.py @@ -0,0 +1,99 @@ +""" +认证核心工具模块。 +提供密码哈希验证、加密,以及 JWT Token 的生成和解析功能。 +""" + +# backend/app/core/auth.py + +from datetime import datetime, timedelta, timezone +from typing import Optional +from jose import jwt, JWTError +from passlib.context import CryptContext +from core.config import settings # 使用 settings +from core.exceptions import TokenInvalidException +from core.log import log + +# 密码哈希上下文 +# 使用 bcrypt_sha256 以消除 72 字节限制,并兼容历史 bcrypt 哈希 +pwd_context = CryptContext( + schemes=["bcrypt_sha256", "bcrypt"], + deprecated="auto" +) + +# 1. 密码验证 +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + 验证密码是否匹配。 + + Args: + plain_password (str): 明文密码。 + hashed_password (str): 哈希后的密码。 + + Returns: + bool: 如果密码匹配返回 True,否则返回 False。 + """ + try: + return pwd_context.verify(plain_password, hashed_password) + except ValueError as e: + # 典型场景:bcrypt 对原始口令有 72 字节限制;长口令在旧 bcrypt 哈希上会触发该异常。 + # 这里吞掉异常并返回 False,让上层统一走密码错误流程。 + log.warning("Password verify failed: {}", str(e)) + return False + +# 2. 密码加密 +def get_password_hash(password: str) -> str: + """ + 对密码进行哈希加密。 + + Args: + password (str): 明文密码。 + + Returns: + str: 加密后的哈希字符串。 + """ + return pwd_context.hash(password) + +# 3. 解析 JWT Token (纯函数,不查 Redis,只解密) +def decode_jwt_token(token: str) -> int: + """ + 解析 JWT Token 获取用户 ID。 + + Args: + token (str): JWT Token 字符串。 + + Returns: + int: 用户 ID。 + + Raises: + TokenInvalidException: 如果 Token 无效或过期。 + """ + try: + payload = jwt.decode( + token, + settings.jwt.secret_key, + algorithms=[settings.jwt.algorithm] + ) + user_id: str = payload.get("sub") + if user_id is None: + raise TokenInvalidException() + + return int(user_id) + except JWTError: + raise TokenInvalidException() + +# 4. 创建 JWT Token +def create_access_token(data: dict) -> str: + """ + 创建 JWT 访问令牌。 + + Args: + data (dict): 需要编码到 Token 中的数据(如 sub)。 + + Returns: + str: 生成的 JWT Token 字符串。 + """ + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(seconds=settings.jwt.token_expire_time_seconds) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.jwt.secret_key, algorithm=settings.jwt.algorithm) + return encoded_jwt diff --git a/src/backend/app/core/config.py b/src/backend/app/core/config.py new file mode 100644 index 0000000..7e7810a --- /dev/null +++ b/src/backend/app/core/config.py @@ -0,0 +1,101 @@ +""" +配置管理模块。 + +负责加载和管理应用程序的配置,支持不同环境(dev/prod)的 YAML 配置文件加载。 +支持环境变量覆盖 YAML 配置(格式:SECTION__KEY,如 DB__HOST) +""" + +# backend/app/core/config.py + +import os +import yaml +import os +from functools import lru_cache + +from config.base import BaseConfig +from core.profile import Profile +from core.log import log + + +def _override_with_env(config_dict: dict) -> dict: + """ + 使用环境变量覆盖配置字典中的值。 + + 环境变量格式:SECTION__KEY(双下划线分隔) + 例如:DB__HOST=postgres-meta 会覆盖 config_dict['db']['host'] + """ + # 定义需要检查的配置段及其环境变量前缀 + env_mappings = { + 'db': 'DB__', + 'redis': 'REDIS__', + 'chroma': 'CHROMA__', + 'mysql': 'MYSQL__', + 'postgresql': 'POSTGRESQL__', + } + + for section, prefix in env_mappings.items(): + if section not in config_dict: + continue + + for key in config_dict[section].keys(): + env_key = f"{prefix}{key.upper()}" + env_value = os.getenv(env_key) + if env_value is not None: + # 尝试转换类型 + original_value = config_dict[section][key] + if isinstance(original_value, bool): + config_dict[section][key] = env_value.lower() in ('true', '1', 'yes') + elif isinstance(original_value, int): + config_dict[section][key] = int(env_value) + elif isinstance(original_value, float): + config_dict[section][key] = float(env_value) + else: + config_dict[section][key] = env_value + log.info("Override config {}.{} with env {}", section, key, env_key) + + return config_dict + + +@lru_cache() +def get_config(config_file="config.yaml", env=None) -> BaseConfig: + """ + 获取对应的环境变量配置。 + + Args: + config_file (str): 配置文件名称,默认为 "config.yaml"。 + env (str, optional): 指定环境(如 "dev", "prod")。如果未提供,将尝试从配置文件中读取。 + + Returns: + BaseConfig: 对应环境的配置对象实例。 + """ + # 获取项目根目录 + project_root = Profile.get_project_root() + # 获取config文件路径 + config_path = project_root.joinpath(config_file) + # 读取 yaml 配置文件 + with open(config_path, "r", encoding="utf-8") as f: + log.info("Load config from {}", config_path) + yaml_config = yaml.safe_load(f) + + # 获取环境是dev还是prod + if env: + yaml_config["env"] = env + log.info("Use env {}", env) + else: + # 优先从环境变量获取 APP_ENV + env = os.getenv("APP_ENV") + if not env: + env = yaml_config.get("env", "dev") + log.info("Use env {}", env) + + # 根据环境获取对应的配置 + env_config = yaml_config.get(env, {}) + + # 使用环境变量覆盖配置 + env_config = _override_with_env(env_config) + + # 返回对应的配置类实例 + return BaseConfig(**env_config) + +settings = get_config() +config = settings # 保留 config 别名,防止其他旧代码报错 diff --git a/src/backend/app/core/database.py b/src/backend/app/core/database.py new file mode 100644 index 0000000..8c3a569 --- /dev/null +++ b/src/backend/app/core/database.py @@ -0,0 +1,149 @@ +""" +数据库连接核心模块。 + +提供 PostgreSQL 数据库的异步连接、会话管理及资源释放功能。 +""" + +# backend/app/core/database.py + +from contextlib import asynccontextmanager + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncAttrs, AsyncSession, async_sessionmaker +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from collections.abc import AsyncGenerator +from fastapi import Depends + +from config.base import DatabaseConfig + +from sqlalchemy.exc import SQLAlchemyError + + + +class Base(AsyncAttrs, DeclarativeBase): + """ + 数据库基础模型。 + + 所有 ORM 模型应继承此类。 + """ + +class PsqlHelper: + """ + PostgresSQL 数据库连接处理类。 + """ + + @staticmethod + def _get_async_engine(db_config: DatabaseConfig) -> AsyncEngine: + """ + 创建异步引擎。 + + Args: + db_config (DatabaseConfig): 数据库配置对象。 + + Returns: + AsyncEngine: 初始化的数据库异步引擎。 + """ + + return create_async_engine( + url=db_config.sqlalchemy_database_url, + echo=db_config.echo, + pool_recycle=db_config.pool_recycle, + pool_timeout=db_config.pool_timeout, + pool_size=db_config.pool_size, + max_overflow=db_config.max_overflow, + ) + + @staticmethod + def _get_async_session(async_engine: AsyncEngine) -> AsyncSession: + """ + 获取异步会话生成器。 + + Args: + async_engine (AsyncEngine): 数据库异步引擎。 + + Returns: + AsyncSession: 异步会话实例。 + + Raises: + ValueError: 如果异步引擎未初始化。 + """ + + if not async_engine: + raise ValueError("Async engine is not initialized") + return async_sessionmaker( + bind=async_engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, + )() + + @classmethod + async def init_conn_psql(cls, db_config: DatabaseConfig) -> AsyncEngine: + """ + 初始化数据库连接。 + + 创建引擎并确保所有模型表结构已创建。 + + Args: + db_config (DatabaseConfig): 数据库配置对象。 + + Returns: + AsyncEngine: 初始化的数据库异步引擎。 + """ + + engine = cls._get_async_engine(db_config) + # 创建Model对应的数据库库表 + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + return engine + + @classmethod + @asynccontextmanager + async def get_session(cls, async_engine: AsyncEngine) -> AsyncGenerator[AsyncSession, None]: + """ + 获取数据库会话 (使用上下文管理器)。 + + 提供事务管理的会话,自动提交或回滚。 + + Args: + async_engine (AsyncEngine): 已初始化的异步引擎。 + + Yields: + AsyncSession: 数据库会话。 + + Raises: + ValueError: 如果异步引擎未初始化。 + Exception: 数据库操作异常时抛出。 + """ + if not async_engine: + raise ValueError("Async engine is not initialized") + + session_factory = async_sessionmaker( + bind=async_engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, + class_=AsyncSession, + ) + session = session_factory() + + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + @classmethod + async def close_conn_psql(cls, async_engine: AsyncEngine) -> None: + """ + 关闭数据库连接。 + + Args: + async_engine (AsyncEngine): 数据库异步引擎。 + """ + + await async_engine.dispose() diff --git a/src/backend/app/core/deps.py b/src/backend/app/core/deps.py new file mode 100644 index 0000000..5acd328 --- /dev/null +++ b/src/backend/app/core/deps.py @@ -0,0 +1,214 @@ +""" +依赖注入模块。 + +提供数据库引擎、会话、认证等的依赖注入功能。 +""" + +# backend/app/core/deps.py + +from typing import AsyncGenerator +from fastapi import Request, Depends, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy import select + +from core.database import PsqlHelper, SQLAlchemyError +from core.exceptions import DatabaseOperationFailedException, ForbiddenException, ItemNotFoundException +from core.config import settings +from core.auth import decode_jwt_token +from models.user_account import UserAccount +from core.log import log + +# --- 定义 OAuth2 流程 (Swagger 用的那个) --- + +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl=f"{settings.app.api}/auth/swagger_login" +) + +# 1. DB 引擎 +async def get_engine(request: Request) -> AsyncEngine: + """ + 从 Request 对象中获取数据库异步引擎。 + + Args: + request (Request): FastAPI 请求对象。 + + Returns: + AsyncEngine: 数据库异步引擎。 + """ + return request.app.state.psql_engine + +# 2. DB 会话 +async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]: + """ + 获取数据库会话生成器。 + + 用于依赖注入,提供每个请求独立的数据库会话。 + + Args: + request (Request): FastAPI 请求对象。 + + Yields: + AsyncSession: 数据库异步会话。 + + Raises: + DatabaseOperationFailedException: 获取会话失败时抛出。 + """ + # 先获取引擎,在 yield 之前处理引擎初始化错误 + try: + engine = await get_engine(request) + except AttributeError: + raise DatabaseOperationFailedException("database engine not initialized") + + if engine is None: + raise DatabaseOperationFailedException("database engine not initialized") + + # 使用会话,不在这里捕获 AttributeError,避免误捕获业务代码中的异常 + try: + async with PsqlHelper.get_session(engine) as session: + yield session + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get database session") from e + + +# 3. 获取当前用户(带黑名单检查) +async def get_current_user( + request: Request, + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +) -> UserAccount: + """ + 从 JWT Token 中获取当前用户。 + + 验证流程: + 1. 解析 JWT Token 获取用户 ID + 2. 查询数据库获取用户对象 + 3. 检查用户账户状态(status: normal/suspended/banned) + 4. 检查请求频率是否超限(Redis) + + Args: + request (Request): FastAPI 请求对象。 + token (str): JWT Token。 + db (AsyncSession): 数据库会话。 + + Returns: + UserAccount: 当前用户对象。 + + Raises: + ForbiddenException: Token 无效或用户被封禁时抛出。 + ItemNotFoundException: 用户不存在时抛出。 + """ + try: + # 1. 解析 Token + user_id = decode_jwt_token(token) + except Exception as e: + log.warning("Token 解析失败: {}", e) + raise ForbiddenException(message="无效的认证凭据") + + try: + # 2. 查询用户 + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == user_id) + ) + user = result.scalar_one_or_none() + + if not user: + log.warning("用户 {} 不存在", user_id) + raise ItemNotFoundException(message="User not found") + + # 3. 检查用户状态 + # 只有 banned 状态禁止访问,suspended 状态只是标签,不限制功能 + if user.status == 'banned': + log.warning("用户 {} 已被封禁", user_id) + raise ForbiddenException(message="Account is banned. Please contact administrator.") + + # suspended 状态只记录日志,不阻止访问 + if user.status == 'suspended': + log.info("用户 {} 被标记为异常,但允许正常访问", user_id) + + # 4. 检查请求频率(仅对普通用户) + # 管理员不受频率限制 + if not user.is_admin: + from core.security import freq_limiter, RemoteLoginDetector + is_exceeded, count = await freq_limiter.check_frequency( + user_id, + time_window=10, + threshold=200 + ) + + from core.utils import get_client_ip + ip_address = get_client_ip(request) + + if is_exceeded: + log.warning("用户 {} 请求频率超限: {} 请求/10秒", user_id, count) + # 记录违规行为 + try: + from core.security import ViolationLogger + await ViolationLogger.log_violation( + db, + user_id, + ViolationLogger.EVENT_EXCESSIVE_API_USAGE, + f"请求频率超限: {count} 请求在 10 秒内", + risk_level=ViolationLogger.RISK_MEDIUM, + ip_address=ip_address, + client_user_agent=request.headers.get("user-agent") + ) + await db.commit() + except Exception as e: + log.error("记录违规日志失败: {}", e) + + # 5. 检查IP频繁变更(30分钟内变动15次) + try: + is_ip_changed_frequently, ip_change_desc = await RemoteLoginDetector.check_frequent_ip_changes( + user_id, + ip_address + ) + if is_ip_changed_frequently: + from core.security import ViolationLogger + await ViolationLogger.log_violation( + db, + user_id, + ViolationLogger.EVENT_FREQUENT_REMOTE_LOGIN, + ip_change_desc, + risk_level=ViolationLogger.RISK_HIGH, + ip_address=ip_address, + client_user_agent=request.headers.get("user-agent") + ) + await db.commit() + except Exception as e: + log.error("IP变更检测执行失败: {}", e) + else: + log.debug("用户 {} 是管理员,跳过频率和IP变更检测", user_id) + + except Exception as e: + log.error("用户认证过程发生未知错误: {}", e) + # 如果是已知异常直接抛出 + if isinstance(e, (ForbiddenException, ItemNotFoundException)): + raise e + # 其他异常转为认证失败 + raise ForbiddenException(message="认证失败") + + return user + + +# 4. 获取当前管理员用户(需要 admin 权限) +async def get_current_admin( + current_user: UserAccount = Depends(get_current_user) +) -> UserAccount: + """ + 获取当前用户,并验证其是否为管理员。 + + Args: + current_user (UserAccount): 当前用户。 + + Returns: + UserAccount: 当前用户(已验证为管理员)。 + + Raises: + ForbiddenException: 用户不是管理员时抛出。 + """ + if not current_user.is_admin: + log.warning("非管理员用户 {} 尝试访问管理员功能", current_user.user_id) + raise ForbiddenException(message="Admin privileges required") + + return current_user diff --git a/src/backend/app/core/email_utils.py b/src/backend/app/core/email_utils.py new file mode 100644 index 0000000..986134a --- /dev/null +++ b/src/backend/app/core/email_utils.py @@ -0,0 +1,237 @@ +""" +邮件工具模块。 + +提供邮件内容生成、验证码生成、邮件发送及验证功能。 +""" + +# backend/app/core/email_utils.py + +import random +from email.header import Header +from email.mime.text import MIMEText + +from aiosmtplib import SMTP + +from core.config import config +from core.log import log +from redis_client.redis import get_redis +from redis_client.redis_keys import redis_key_manager + +def _generate_code(length: int = 6) -> str: + """ + 生成指定长度的随机数字验证码。 + + Args: + length (int): 验证码长度,默认6位。 + + Returns: + str: 随机生成的数字字符串。 + """ + return "".join([str(random.randint(0, 9)) for _ in range(length)]) + +def make_email_content(verify_code: str): + """ + 生成包含验证码的 HTML 邮件内容。 + + Args: + verify_code (str): 验证码。 + + Returns: + str: HTML 格式的邮件内容字符串。 + """ + email_content = f""" + + +

亲爱的用户:

+

你正在进行邮箱验证操作,你的验证码为:

+

{verify_code}

+

验证码有效期为 {int(config.smtp.expire_time_seconds / 60)} 分钟,请尽快完成验证。

+

若你未发起此操作,请忽略本邮件,感谢你的使用!

+ + + """ + return email_content + +async def send_verify_email(to_email: str, subject: str = "【auto_db_deployment】邮箱验证码"): + """ + 异步向指定邮箱发送验证码。 + + 生成验证码,发送邮件,并将验证码存储到 Redis 中。 + + Args: + to_email (str): 接收者邮箱地址。 + subject (str): 邮件主题。 + + Returns: + bool: 发送成功返回 True,失败返回 False。 + """ + verify_code = _generate_code() + log.info(f"Generated verification code {verify_code} for {to_email}") + + email_content = make_email_content(verify_code) + msg = MIMEText(email_content, "html", "utf-8") + msg["From"] = config.smtp.sender + msg["To"] = to_email + msg["Subject"] = Header(subject, "utf-8") + + log.info(f"Email From: {msg['From']}, To: {msg['To']}, Subject: {msg['Subject']}") + + try: + redis = get_redis() + if redis is None: + raise ValueError("Redis is not initialized") + + async with SMTP( + hostname=config.smtp.host, + port=config.smtp.port, + username=config.smtp.sender, + password=config.smtp.key, + use_tls=True, + ) as smtp: + await smtp.send_message( + msg, + sender=config.smtp.sender, + recipients=[to_email], + ) + log.info(f"Sent verification code {verify_code} to {to_email}") + + await redis.setex( + redis_key_manager.get_verification_key(to_email), + config.smtp.expire_time_seconds, + verify_code, + ) + return True + except ValueError as e: + log.error(f"Value error in send_verify_email: {e}") + return False + except Exception as e: + log.error(f"Unexpected error in send_verify_email: {e}") + return False + +async def verify_code(to_email: str, code: str) -> bool: + """ + 验证邮箱和验证码是否匹配。 + + 从 Redis 中获取存储的验证码进行比对。 + + Args: + to_email (str): 邮箱地址。 + code (str): 待验证的验证码。 + + Returns: + bool: 验证成功返回 True,失败或过期返回 False。 + """ + try: + redis = get_redis() + if redis is None: + raise ValueError("Redis is not initialized") + stored_code = await redis.get(redis_key_manager.get_verification_key(to_email)) + if stored_code is None: + return False + return stored_code == code + except ValueError as e: + log.error(f"Value error in verify_code: {e}") + return False + except Exception as e: + log.error(f"Unexpected error in verify_code: {e}") + return False + + +def make_password_reset_email_content(reset_url: str, expire_minutes: int) -> str: + """生成包含密码重置链接的 HTML 邮件内容。""" + return f""" + + +

亲爱的用户:

+

你正在进行密码重置操作,请点击下方链接设置新密码:

+

+ 重置密码 +

+

如果按钮无法点击,请复制以下链接到浏览器打开:

+

{reset_url}

+

该链接有效期为 {expire_minutes} 分钟,请尽快完成操作。

+

若你未发起此操作,请忽略本邮件。

+ + + """ + + +async def send_password_reset_email( + to_email: str, + reset_url: str, + subject: str = "【AutoDB】密码重置", + expire_minutes: int = 15, +) -> bool: + """异步发送密码重置邮件(不写 Redis,由调用方负责 token 存储)。""" + email_content = make_password_reset_email_content(reset_url, expire_minutes) + msg = MIMEText(email_content, "html", "utf-8") + msg["From"] = config.smtp.sender + msg["To"] = to_email + msg["Subject"] = Header(subject, "utf-8") + + try: + async with SMTP( + hostname=config.smtp.host, + port=config.smtp.port, + username=config.smtp.sender, + password=config.smtp.key, + use_tls=True, + ) as smtp: + await smtp.send_message( + msg, + sender=config.smtp.sender, + recipients=[to_email], + ) + log.info(f"Sent password reset email to {to_email}") + return True + except Exception as e: + log.error(f"Unexpected error in send_password_reset_email: {e}") + return False + + +def make_password_reset_code_email_content(verify_code: str, expire_minutes: int) -> str: + """生成包含密码重置验证码的 HTML 邮件内容。""" + return f""" + + +

亲爱的用户:

+

你正在进行密码重置操作,你的验证码为:

+

{verify_code}

+

验证码有效期为 {expire_minutes} 分钟,请尽快完成操作。

+

若你未发起此操作,请忽略本邮件。

+ + + """ + + +async def send_password_reset_code_email( + to_email: str, + verify_code: str, + subject: str = "【AutoDB】密码重置验证码", + expire_minutes: int = 10, +) -> bool: + """异步发送密码重置验证码邮件(不写 Redis,由调用方负责存储/校验)。""" + email_content = make_password_reset_code_email_content(verify_code, expire_minutes) + msg = MIMEText(email_content, "html", "utf-8") + msg["From"] = config.smtp.sender + msg["To"] = to_email + msg["Subject"] = Header(subject, "utf-8") + + try: + async with SMTP( + hostname=config.smtp.host, + port=config.smtp.port, + username=config.smtp.sender, + password=config.smtp.key, + use_tls=True, + ) as smtp: + await smtp.send_message( + msg, + sender=config.smtp.sender, + recipients=[to_email], + ) + log.info(f"Sent password reset code email to {to_email}") + return True + except Exception as e: + log.error(f"Unexpected error in send_password_reset_code_email: {e}") + return False diff --git a/src/backend/app/core/exception_handlers.py b/src/backend/app/core/exception_handlers.py new file mode 100644 index 0000000..118b1fc --- /dev/null +++ b/src/backend/app/core/exception_handlers.py @@ -0,0 +1,382 @@ +""" +统一异常处理模块。 + +提供全局异常处理器,统一处理各种类型的异常并返回标准化响应。 +""" + +from fastapi import Request, status +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError +from sqlalchemy.exc import SQLAlchemyError + +# 第三方库异常导入 +import redis.exceptions as redis_exceptions +import jose.exceptions as jose_exceptions +import httpx +import aiohttp +import celery.exceptions as celery_exceptions + +from .exceptions import BusinessException, AppException, SQLConversionException, UserNotFoundException, PasswordInvalidException +from .log import log +from schema.unified_response import UnifiedResponse + + +# 错误代码常量定义 +class ErrorCodes: + """错误代码常量类""" + # 参数验证错误 + PARAM_VALIDATION_ERROR = 10001 + DATA_VALIDATION_ERROR = 10002 + + # 数据库错误 + DATABASE_OPERATION_ERROR = 20001 + SQL_CONVERSION_ERROR = 20007 + + # 系统内部错误 + SYSTEM_INTERNAL_ERROR = 20002 + IO_OPERATION_ERROR = 20003 + TIMEOUT_ERROR = 20004 + TYPE_ERROR = 20005 + VALUE_ERROR = 20006 + KEY_ATTRIBUTE_ERROR = 20009 + + # 第三方服务错误 + REDIS_OPERATION_ERROR = 20008 + CELERY_TASK_ERROR = 20010 + HTTP_REQUEST_ERROR = 20011 + + # 认证错误 + AUTHENTICATION_ERROR = 401 + + +# ====================== +# 自定义异常处理器 +# ====================== + +async def business_exception_handler(request: Request, exc: BusinessException) -> JSONResponse: + """ + 处理业务异常。 + + Args: + request (Request): HTTP请求对象 + exc (BusinessException): 业务异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + log.warning("BusinessException: {}", exc.message, extra={"path": request.url.path, "method": request.method}) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=exc.code, + message=exc.message, + data=None + ).dict() + ) + + +async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse: + """ + 处理应用级异常。 + + Args: + request (Request): HTTP请求对象 + exc (AppException): 应用级异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + log.error("AppException: {}", exc.detail, extra={"path": request.url.path, "method": request.method}, exc_info=True) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=exc.code, + message=exc.message or "系统处理失败", + data=None + ).dict() + ) + + +# ====================== +# 验证异常处理器 +# ====================== + +async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + """ + 处理请求参数验证异常。 + + Args: + request (Request): HTTP请求对象 + exc (RequestValidationError): 请求验证异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + error_info = exc.errors() + log.warning("ValidationError: {}", error_info, extra={"path": request.url.path, "method": request.method}) + + # 提取验证错误信息 + error_details = [] + for error in exc.errors(): + msg = error['msg'] + if ', ' in msg: + msg = msg.split(', ', 1)[1] + error_details.append(f"{msg}") + + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=ErrorCodes.PARAM_VALIDATION_ERROR, + message=". ".join(error_details), + data=None + ).dict() + ) + + +async def pydantic_validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse: + """ + 处理Pydantic模型验证异常。 + + Args: + request (Request): HTTP请求对象 + exc (ValidationError): Pydantic验证异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + error_info = exc.errors() + log.warning("PydanticValidationError: {}", error_info, extra={"path": request.url.path, "method": request.method}) + + # 提取验证错误信息 + error_details = [] + for error in exc.errors(): + # 移除错误信息中的英文前缀,保留中文部分 + msg = error['msg'] + if ', ' in msg: + msg = msg.split(', ', 1)[1] + error_details.append(f"{msg}") + + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=ErrorCodes.DATA_VALIDATION_ERROR, + message=". ".join(error_details), + data=None + ).dict() + ) + + +# ====================== +# 数据库异常处理器 +# ====================== + +async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) -> JSONResponse: + """ + 处理数据库操作异常。 + + Args: + request (Request): HTTP请求对象 + exc (SQLAlchemyError): SQLAlchemy数据库异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + log.error("SQLAlchemyError: {}", str(exc), extra={"path": request.url.path, "method": request.method}, exc_info=True) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=ErrorCodes.DATABASE_OPERATION_ERROR, + message="数据库操作失败", + data=None + ).dict() + ) + + +async def sql_conversion_exception_handler(request: Request, exc: SQLConversionException) -> JSONResponse: + """ + 处理SQL转换异常。 + + Args: + request (Request): HTTP请求对象 + exc (SQLConversionException): SQL转换异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + log.warning("SQLConversionException: {}", exc.message, extra={"path": request.url.path, "method": request.method}) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=exc.code, + message=exc.message, + data=None + ).dict() + ) + + +# ====================== +# 内置异常处理器 +# ====================== + +async def io_exception_handler(request: Request, exc: IOError | FileNotFoundError) -> JSONResponse: + """ + 处理IO异常,如文件操作失败等。 + + Args: + request (Request): HTTP请求对象 + exc (IOError | FileNotFoundError): IO异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + error_message = f"文件操作失败: {str(exc)}" + log.error("IOError: {}", error_message, extra={"path": request.url.path, "method": request.method}, exc_info=True) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=ErrorCodes.IO_OPERATION_ERROR, + message=error_message, + data=None + ).dict() + ) + + +async def timeout_exception_handler(request: Request, exc: TimeoutError) -> JSONResponse: + """ + 处理超时异常,如网络请求超时等。 + + Args: + request (Request): HTTP请求对象 + exc (TimeoutError): 超时异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + log.error("TimeoutError: {}", str(exc), extra={"path": request.url.path, "method": request.method}, exc_info=True) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=ErrorCodes.TIMEOUT_ERROR, + message="请求超时,请稍后重试", + data=None + ).dict() + ) + + +async def type_exception_handler(request: Request, exc: TypeError) -> JSONResponse: + """ + 处理类型错误异常。 + + Args: + request (Request): HTTP请求对象 + exc (TypeError): 类型错误异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + log.error("TypeError: {}", str(exc), extra={"path": request.url.path, "method": request.method}, exc_info=True) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=ErrorCodes.TYPE_ERROR, + message="系统内部类型错误", + data=None + ).dict() + ) + + +async def value_exception_handler(request: Request, exc: ValueError) -> JSONResponse: + """ + 处理值错误异常。 + + Args: + request (Request): HTTP请求对象 + exc (ValueError): 值错误异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + log.error("ValueError: {}", str(exc), extra={"path": request.url.path, "method": request.method}, exc_info=True) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=ErrorCodes.VALUE_ERROR, + message="系统内部值错误", + data=None + ).dict() + ) + + +async def key_exception_handler(request: Request, exc: KeyError | AttributeError) -> JSONResponse: + """ + 处理键错误和属性错误异常。 + + Args: + request (Request): HTTP请求对象 + exc (KeyError | AttributeError): 键错误或属性错误异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + log.error("Key/AttributeError: {}", str(exc), extra={"path": request.url.path, "method": request.method}, exc_info=True) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=ErrorCodes.KEY_ATTRIBUTE_ERROR, + message="系统内部数据访问错误", + data=None + ).dict() + ) + + +# ====================== +# 第三方库异常处理器 +# ====================== + +async def jwt_exception_handler(request: Request, exc: jose_exceptions.JWTError) -> JSONResponse: + """ + 处理JWT相关异常。 + + Args: + request (Request): HTTP请求对象 + exc (jose_exceptions.JWTError): JWT异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + log.error("JWTError: {}", str(exc), extra={"path": request.url.path, "method": request.method}, exc_info=True) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=ErrorCodes.AUTHENTICATION_ERROR, + message="令牌无效或已过期", + data=None + ).dict() + ) + + +# ====================== +# 兜底异常处理器 +# ====================== + +async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """ + 处理通用异常,作为兜底异常处理器。 + + Args: + request (Request): HTTP请求对象 + exc (Exception): 通用异常实例 + + Returns: + JSONResponse: 标准化的错误响应 + """ + log.error("UnexpectedException: {}", str(exc), extra={"path": request.url.path, "method": request.method}, exc_info=True) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=UnifiedResponse.error( + code=ErrorCodes.SYSTEM_INTERNAL_ERROR, + message="系统处理失败", + data=None + ).dict() + ) \ No newline at end of file diff --git a/src/backend/app/core/exceptions.py b/src/backend/app/core/exceptions.py new file mode 100644 index 0000000..708f01c --- /dev/null +++ b/src/backend/app/core/exceptions.py @@ -0,0 +1,187 @@ +""" +异常处理模块。 + +定义业务异常和应用异常基类,以及各种具体的业务异常类。 +""" + +# backend/app/core/exceptions.py + +from typing import Optional + + +class BusinessException(Exception): + """ + 业务异常基类。 + + 所有业务逻辑相关的异常都应继承此类。 + + Attributes: + code (int): 对应 HTTP 状态码。 + message (str): 用户可读的异常信息。 + """ + code: int # 对应 HTTP 状态码(方便后续转换) + message: str # 异常信息 + + def __init__(self, code: int, message: str): + self.code = code + self.message = message + super().__init__(message) + +class AppException(Exception): + """ + 应用级异常基类。 + + 用于处理系统内部错误、配置错误等非业务逻辑异常。 + + Attributes: + code (int): HTTP 状态码。 + message (str): 默认异常信息。 + detail (Optional[str]): 开发者可见的详细错误信息。 + """ + code: int # HTTP状态码 + message: str = "内部错误" # 用户可读的异常信息 + detail: Optional[str] = None # 开发者可见的详细错误 + + def __init__(self, code: int, detail: Optional[str] = None): + self.code = code + self.detail = detail + super().__init__(detail) + +# 具体业务异常(按需定义) +class TokenInvalidException(BusinessException): + """Token 无效/解析失败异常。""" + def __init__(self): + super().__init__(code=401, message="无法验证凭据") + +class EmailHasBeenRegisteredException(BusinessException): + """邮箱已被注册异常。""" + def __init__(self): + super().__init__(code=400, message="邮箱已被注册") + +class UsernameHasBeenRegisteredException(BusinessException): + """用户名已被注册异常。""" + def __init__(self): + super().__init__(code=400, message="用户名已被注册") + +class SendVerificationCodeFailedException(BusinessException): + """发送验证码失败异常。""" + def __init__(self): + super().__init__(code=500, message="发送验证码失败") + +class CodeInvalidException(BusinessException): + """验证码无效或已过期异常。""" + def __init__(self): + super().__init__(code=400, message="验证码无效或已过期") + +class UserNotFoundException(BusinessException): + """用户不存在异常。""" + def __init__(self): + super().__init__(code=401, message="用户不存在") + +class PasswordMismatchException(BusinessException): + """密码不匹配异常。""" + def __init__(self): + super().__init__(code=400, message="密码不匹配") + +class UserStatusForbiddenException(BusinessException): + """ + 用户状态异常(禁止访问)。 + + Attributes: + user_id (int): 用户 ID。 + """ + user_id: int + def __init__(self, status: str, user_id: int): + if status == 'suspended': + message = "账户已被锁定(登录失败次数过多),请联系管理员解锁" + elif status == 'banned': + message = "账户已被封禁,请联系管理员" + else: + message = f"用户账号状态异常 ({status}),请联系管理员" + super().__init__(code=403, message=message) + self.user_id = user_id + self.user_id = user_id + +class PasswordInvalidException(BusinessException): + """ + 密码错误异常。 + + Attributes: + user_id (int): 用户 ID。 + failed_attempts (int): 连续失败次数。 + """ + user_id: int + failed_attempts: int + def __init__(self, user_id: int = 0, failed_attempts: int = 0, custom_message: str = None): + if custom_message: + message = custom_message + else: + message = "密码错误" + super().__init__(code=401, message=message) + self.user_id = user_id + self.failed_attempts = failed_attempts + +class ValidationException(BusinessException): + """数据验证失败异常。""" + def __init__(self, message: str = "数据验证失败"): + super().__init__(code=400, message=message) + +class EmailNotVerifiedException(BusinessException): + """邮箱未验证异常。""" + def __init__(self): + super().__init__(code=400, message="邮箱未验证") + +class UserAlreadyExistsException(BusinessException): + """用户已存在异常。""" + def __init__(self): + super().__init__(code=400, message="用户已存在") + +class OperationNotPermittedException(BusinessException): + """操作不允许异常。""" + def __init__(self, message: str = "操作不允许"): + super().__init__(code=403, message=message) + +class ForbiddenException(BusinessException): + """禁止访问异常。""" + def __init__(self, message: str = "禁止访问"): + super().__init__(code=403, message=message) + +class ItemNotFoundException(BusinessException): + """资源未找到异常。""" + def __init__(self, message: str = "资源未找到"): + super().__init__(code=404, message=message) + +class InvalidOperationException(BusinessException): + """无效的操作异常。""" + def __init__(self, message: str = "无效的操作"): + super().__init__(code=400, message=message) + +class SQLSecurityException(BusinessException): + """SQL 安全异常。""" + def __init__(self, message: str = "SQL安全异常"): + super().__init__(code=500, message=message) + +class DatabaseOperationFailedException(AppException): + """数据库操作失败异常。""" + def __init__(self, operation: str = "操作", ): + super().__init__(code=500, detail=f"数据库 {operation} 失败") + +class RedisOperationFailedException(AppException): + """Redis 操作失败异常。""" + def __init__(self, operation: str = "操作"): + super().__init__(code=500, detail=f"Redis {operation} 失败") + + +class SQLOperationFailedException(AppException): + """SQL 执行失败异常。""" + def __init__(self, operation: str = "操作", detail: str = ""): + if detail: + super().__init__(code=500, detail=f"SQL {operation} 失败: {detail}") + else: + super().__init__(code=500, detail=f"SQL {operation} 失败") + + +class SQLConversionException(BusinessException): + """SQL 转换异常。""" + def __init__(self, message: str = "SQL转换失败"): + super().__init__(code=500, message=message) diff --git a/src/backend/app/core/log.py b/src/backend/app/core/log.py new file mode 100644 index 0000000..a8f2ea9 --- /dev/null +++ b/src/backend/app/core/log.py @@ -0,0 +1,78 @@ +# backend/app/core/log.py + +import sys +from functools import lru_cache +from loguru import logger + +from .profile import Profile + +class LogHelper: + """ + 日志系统辅助类。 + + 用于配置和管理应用的日志记录器,支持控制台和文件输出。 + """ + + def __init__(self, log_file_name: str = "log"): + """ + 初始化日志配置。 + + 配置日志记录器,移除默认处理器,添加控制台和文件处理器,设置日志格式和轮转策略。 + + Args: + log_file_name (str): 日志文件名前缀,默认为 "log"。 + """ + # 初始化日志记录器 + self.logger = logger + # 移除所有已有的日志处理器,情况日志设置 + self.logger.remove() + + # 使用Profile获取项目根目录 + # 确保日志目录存在,如果不存在就创建 + log_file_path = Profile.get_project_root() / "logs" + log_file_path.mkdir(parents=True, exist_ok=True) + log_file_path = log_file_path / f"{log_file_name}.log" + + # 定义日志输出的基本格式 + formatter = ( + "{time:YYYYMMDD HH:mm:ss} | " + "{process.name} | " + "{thread.name} | " + "{module}.{function} | " + "{level} | " + "{message}" + ) + + # 添加控制台输出 (default levels until config is loaded) + self.logger.add( + sink=sys.stdout, + format=formatter, + level="INFO", + ) + + # 添加文件输出 (default levels until config is loaded) + self.logger.add( + sink=log_file_path, + format=formatter, + level="DEBUG", + rotation="500 MB", + retention="10 days", + encoding="utf-8" + ) + + @lru_cache() + def get_logger(self): + """ + 获取日志记录器实例。 + + 使用 LRU 缓存以确保单例模式。 + + Returns: + logger: loguru 日志记录器实例。 + """ + return self.logger + +# 创建LogHelper 实例 +log_helper = LogHelper() +# 获取日志记录器 +log = log_helper.get_logger() diff --git a/src/backend/app/core/profile.py b/src/backend/app/core/profile.py new file mode 100644 index 0000000..4feed3f --- /dev/null +++ b/src/backend/app/core/profile.py @@ -0,0 +1,28 @@ +# backend/app/core/profile.py + +from pathlib import Path + +class Profile: + """ + 项目配置辅助类。 + + 用于获取项目相关的路径信息。 + """ + @staticmethod + def get_project_root() -> Path: + """ + 获取项目根目录。 + + 通过向上查找 config.yaml 文件来确定项目根目录。 + 如果未找到,则回退到基于当前文件位置的相对路径。 + + Returns: + Path: 项目根目录路径。 + """ + current_path = Path(__file__) + + for parent in current_path.parents: + if (parent / "config.yaml").exists(): + return parent + + return current_path.parent.parent.parent diff --git a/src/backend/app/core/prompts.py b/src/backend/app/core/prompts.py new file mode 100644 index 0000000..72108ac --- /dev/null +++ b/src/backend/app/core/prompts.py @@ -0,0 +1,276 @@ +""" +Prompt 模板与系统指令配置模块。 + +将硬编码的 Prompt 和 AI 指令从业务代码中抽离,遵循配置解耦原则。 +""" + +# ========================================================= +# SQL 生成相关 Prompt 模板 +# ========================================================= + +SQL_GENERATION_SYSTEM_INSTRUCTION = """You are a specialized SQL generation assistant. +Your ONLY task is to generate valid, executable SQL queries based on the provided database schema and user question. + +[Constraints] +1. Output **ONLY** the raw SQL code. No explanations, no markdown (```sql), no wrapping. +2. If the user asks in Chinese, map it semantically to the English schema. +3. Use the exact table and column names from the schema. +4. If the question cannot be answered with the schema, return SELECT 'ERROR: Cannot answer'; +5. Always use single quotes ('value') for string literals. NEVER use double quotes ("value"). +6. If a term is defined in [Domain Knowledge], you MUST use the exact definition and values provided there. Do NOT use general knowledge. + +[Output Format - CRITICAL] +7. The output must be a DIRECTLY EXECUTABLE SQL statement. +8. For INSERT requests, use the format: INSERT INTO table (...) VALUES (...), (...), (...); for multiple records. DO NOT generate multiple INSERT statements for multiple records. Always combine multiple inserts into a single INSERT statement with multiple VALUES clauses. +9. For SELECT requests, output: SELECT ... FROM ... WHERE ...; +10. For UPDATE requests, output: UPDATE table SET ... WHERE ...; +11. For DELETE requests, output: DELETE FROM table WHERE ...; +12. Even if the user asks the same question multiple times, generate the same type of SQL (INSERT for insert, SELECT for query, etc.) + +IMPORTANT: +- For string literals, YOU MUST USE SINGLE QUOTES ('). +- DO NOT use double quotes (") or backticks (`). +- DO NOT output SQL as a string literal inside SELECT. +- For multiple record insertion, ALWAYS use a single INSERT statement with multiple VALUES tuples: INSERT INTO table (col1, col2) VALUES (val1, val2), (val3, val4), (val5, val6); +- NEVER generate multiple INSERT statements like: INSERT INTO table ...; INSERT INTO table ...; INSERT INTO table ...; + +Examples: +Correct: INSERT INTO users (name, age) VALUES ('John', 25), ('Jane', 30), ('Bob', 35); +Correct: SELECT * FROM users WHERE name = 'John'; +WRONG: INSERT INTO users (name, age) VALUES ('John', 25); INSERT INTO users (name, age) VALUES ('Jane', 30); +WRONG: SELECT 'INSERT INTO users (name) VALUES ('John')'; +WRONG: SELECT * FROM users WHERE name = "John"; +""" + +LOCAL_FINETUNE_SYSTEM_PROMPT = "You are a SQL expert." + + +# ========================================================= +# Prompt 构建辅助函数 +# ========================================================= + +def build_knowledge_section(knowledge: list) -> str: + """ + 构建领域知识部分的 Prompt。 + + Args: + knowledge: 领域知识列表 [DomainKnowledge] + + Returns: + str: 格式化后的知识部分文本 + + TODO: [RAG] 知识注入优化 + 1. 接收的 knowledge 应为 RAG 检索后的结果(已排序) + 2. 可添加相关度分数,供 LLM 参考权重 + 3. 格式可扩展为:Term (score: 0.95): definition + """ + if not knowledge: + return "" + + # TODO: [RAG] 后续可根据检索分数动态调整格式 + term_lines = [f"Strict Rule: '{k.term}' implies {k.definition}" for k in knowledge] + knowledge_content = "\n".join(term_lines) + + return f""" +[Domain Knowledge / Business Terms] +Use these terms to understand the user's intent: +{knowledge_content} +""" + + +def build_history_section(history: list) -> str: + """ + 构建历史对话部分的 Prompt。 + + Args: + history: 历史消息列表 [MessageModel] + + Returns: + str: 格式化后的历史对话文本 + + 注意: + - 只保留用户问题,不拼接 AI 的 SQL 回复(避免干扰) + - 跳过取消操作相关的消息 + + TODO: [RAG] 历史对话注入优化 + 1. 接收的 history 应为 RAG 检索后的结果 + 2. 检索结果按相关度排序,而非时间顺序 + 3. 可考虑混合策略:最近 N 条 + 语义相关 M 条 + 4. 对于 SQL 生成场景,优先召回包含相似表/字段的历史 + """ + if not history: + return "" + + history_lines = [] + + for msg in history: + content = msg.content.strip() + + # 跳过取消操作相关的消息 + if "已取消" in content or "已为您取消" in content: + continue + + # 只保留用户消息,AI 的 SQL 回复不拼接(避免干扰模型判断) + if msg.message_type == "user": + # 清理内容,移除换行 + clean_content = content.replace("\n", " ") + history_lines.append(f"User: {clean_content}") + + if not history_lines: + return "" + + history_content = "\n".join(history_lines) + + return f""" +[Conversation History] +Previous user questions for context: +{history_content}""" + + +def build_context_block( + schema_text: str, + knowledge: list, + history: list, + db_type: str = None +) -> str: + """ + 组装完整的上下文块。 + + Args: + schema_text: 数据库 DDL(或 RAG 检索后的部分 DDL) + knowledge: 领域知识列表(RAG 检索结果) + history: 历史对话列表(RAG 检索结果) + db_type: 数据库类型(mysql/postgresql/sqlite) + + Returns: + str: 完整的上下文块 + + TODO: [RAG] 上下文组装优化 + 1. 动态计算各部分 Token 预算,避免超限 + 2. 优先级:DDL > Knowledge > History + 3. 支持 Token 截断策略(truncate from tail) + 4. 添加上下文来源标注,便于调试 + """ + knowledge_section = build_knowledge_section(knowledge) + history_section = build_history_section(history) + + # 数据库类型信息 + db_type_section = "" + if db_type: + db_type_upper = db_type.upper() + db_type_section = f"\n[Database Type]\nTarget database: {db_type_upper}. Generate SQL compatible with {db_type_upper} syntax.\n" + + # TODO: [RAG] 后续添加 Token 计数和动态截断逻辑 + return f"""[Database DDL] +{schema_text} +{db_type_section}{knowledge_section} +{history_section}""" + + +def build_ai_messages( + model_type: str, + schema_text: str, + question: str, + history: list = None, + knowledge: list = None, + db_type: str = None +) -> list: + """ + 构建发送给 AI 的消息列表。 + + Args: + model_type: 模型类型 ('local_finetune' 或 'general_llm') + schema_text: 数据库 DDL + question: 用户当前问题 + history: 历史对话记录 + knowledge: 领域知识列表 + db_type: 数据库类型(mysql/postgresql/sqlite) + + Returns: + list: 消息字典列表 + """ + history = history or [] + knowledge = knowledge or [] + + context_block = build_context_block(schema_text, knowledge, history, db_type) + + if model_type == "local_finetune": + # CodeLlama-Instruct 使用 INST 格式,直接返回格式化的 prompt 字符串 + # 这样可以使用 /v1/completions 端点,避免服务器应用错误的 chat template + prompt_content = f"""{SQL_GENERATION_SYSTEM_INSTRUCTION} +{context_block} + +### User Question +{question} + +### SQL Query +""" + # 返回 CodeLlama INST 格式的完整 prompt + llama_prompt = f"""[INST] <> +{LOCAL_FINETUNE_SYSTEM_PROMPT} +<> + +{prompt_content} [/INST] +""" + return {"prompt": llama_prompt, "is_completion": True} + else: + full_system_prompt = f"{SQL_GENERATION_SYSTEM_INSTRUCTION}\n{context_block}" + return [ + {"role": "system", "content": full_system_prompt}, + {"role": "user", "content": question} + ] + + +# ========================================================= +# Mermaid ER 图生成相关配置 +# ========================================================= + +MERMAID_ER_GENERATION_PROMPT = """You are a professional database designer. + +Given the following Entity Sets and Relationship Sets, please generate a complete and accurate Mermaid ER diagram code using `erDiagram` syntax. Follow these strict rules: + +1. Use `PK` and `FK` to mark primary and foreign keys in the entity or relationship tables. +2. Use `||--o{`, `||--||`, `o{--o{` etc. to represent correct cardinality: + - `||--o{` means one-to-many + - `||--||` means one-to-one + - `o{--o{` means many-to-many +3. For relationship sets, if needed, create a separate entity-like table to store relationship attributes and foreign keys. +4. Do not include any extra explanation or markdown syntax like ```mermaid. Just return the raw ER diagram code. +5. Use appropriate attribute types like `int`, `string`, `date`, `float`, etc., based on the names. +6. **Attribute Order (CRITICAL)**: + - You MUST follow the format: `Type Name Key`. + - The Key (PK/FK) must ALWAYS be at the **end** of the line. + - **Correct**: `string StudentID PK` + - **WRONG**: `string PK StudentID` (Never put PK/FK before the name) + +7. **Composite Keys (CRITICAL)**: + - If an attribute is **BOTH** a Primary Key and a Foreign Key, you MUST separate them with a **COMMA**. + - **Correct**: `string course_id PK, FK` + - **WRONG**: `string course_id PK FK` (Missing comma causes error) +""" + +MERMAID_THEME_CONFIG = { + "theme": "base", + "themeVariables": { + "primaryColor": "#ffffff", + "primaryTextColor": "#000000", + "primaryBorderColor": "#3370ff", + "lineColor": "#3370ff", + "tertiaryColor": "#e6f7ff", + "tertiaryBorderColor": "#3370ff", + "tertiaryTextColor": "#000000", + "mainBkg": "#ffffff", + "edgeLabelBackground": "#fff" + } +} + + +def build_mermaid_init_directive() -> str: + """ + 构建 Mermaid 初始化指令(包含主题配置)。 + + Returns: + str: Mermaid init 指令字符串 + """ + import json + return f"%%{{init: {json.dumps(MERMAID_THEME_CONFIG)} }}%%\n" diff --git a/src/backend/app/core/security.py b/src/backend/app/core/security.py new file mode 100644 index 0000000..85c336b --- /dev/null +++ b/src/backend/app/core/security.py @@ -0,0 +1,739 @@ +""" +安全检查和频率限制模块。 + +提供 Redis 频率限制、违规日志记录、黑名单管理等功能。 +""" + +# backend/app/core/security.py + +import json +from datetime import datetime, timezone +from typing import Optional, Tuple, Dict, Any +import redis_client +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc, and_ + +from core.config import settings +from core.log import log +from models.violation_log import ViolationLog +from models.user_account import UserAccount + +# ============================================================================ +# 1. Redis 频率限制(三击触发机制) +# ============================================================================ + +class RedisFrequencyLimiter: + """ + 基于 Redis 的频率限制器。 + + 使用三击触发机制: + - 记录用户的请求频率 + - 如果在指定时间窗口内请求次数超过阈值,标记为风险状态 + """ + + def __init__(self): + """ + 初始化 Redis 连接(延迟获取,避免启动时Redis未初始化)。 + """ + self._redis_client = None + + @property + def redis_client(self): + """延迟获取Redis客户端,确保在Redis初始化后获取""" + if self._redis_client is None: + from redis_client.redis import get_redis + self._redis_client = get_redis() + return self._redis_client + + async def check_frequency(self, user_id: int, + time_window: int = 10, # 时间窗口(秒) + threshold: int = 200, # 请求次数阈值(修正为200) + ) -> Tuple[bool, int]: + """ + 检查用户请求频率是否超限。 + + Args: + user_id: 用户ID + time_window: 时间窗口(秒),默认 10 秒 + threshold: 阈值(请求次数),默认 200 次 + + Returns: + Tuple[bool, int]: (是否超限, 当前请求次数) + - True 表示超限,用户应该被限制 + - 当前请求次数 + """ + if not self.redis_client: + log.warning("Redis 不可用,跳过频率限制") + return False, 0 + + try: + from redis_client.redis_keys import redis_key_manager + key = redis_key_manager.get_frequency_limit_key(user_id) + + # INCR 操作:增加请求计数 (异步调用) + current_count = await self.redis_client.incr(key) + + # 第一次请求时设置过期时间 (异步调用) + if current_count == 1: + await self.redis_client.expire(key, time_window) + + # 判断是否超限 + is_exceeded = current_count > threshold + + if is_exceeded: + log.warning("用户 {} 请求频率超限: {}/{}", user_id, current_count, threshold) + + return is_exceeded, current_count + + except Exception as e: + log.error("Redis 频率限制检查失败: {}", e) + return False, 0 + + def get_frequency(self, user_id: int) -> int: + """ + 获取用户当前请求频率。 + + 注意:此方法是同步的,仅用于非关键路径。 + 如需在异步上下文中使用,请改用 async 版本。 + + Args: + user_id: 用户ID + + Returns: + int: 当前请求次数 + """ + if not self.redis_client: + return 0 + + try: + from redis_client.redis_keys import redis_key_manager + key = redis_key_manager.get_frequency_limit_key(user_id) + # 注意:这里使用同步方式,可能会有警告 + # 在生产环境中建议使用异步版本 + count = self.redis_client.get(key) + return int(count) if count else 0 + except Exception as e: + log.error("获取用户频率失败: {}", e) + return 0 + + async def reset_frequency(self, user_id: int) -> bool: + """ + 重置用户请求频率(管理员使用)。 + + Args: + user_id: 用户ID + + Returns: + bool: 是否成功重置 + """ + if not self.redis_client: + return False + + try: + from redis_client.redis_keys import redis_key_manager + key = redis_key_manager.get_frequency_limit_key(user_id) + await self.redis_client.delete(key) + log.info("已重置用户 {} 的请求频率", user_id) + return True + except Exception as e: + log.error("重置用户频率失败: {}", e) + return False + + +# ============================================================================ +# 1.5 登录失败计数器(三次密码错误标记为异常) +# ============================================================================ + +class LoginFailureTracker: + """ + 登录失败追踪器。 + + 记录用户连续输错密码的次数,达到阈值后将用户标记为异常(suspended)。 + 使用 Redis 存储计数,24小时后自动过期。 + """ + + # 密码错误阈值 + MAX_FAILED_ATTEMPTS = 3 + # 计数过期时间(秒)- 24小时 + EXPIRE_SECONDS = 86400 + + def __init__(self): + """初始化 Redis 连接(延迟获取,避免启动时Redis未初始化)""" + self._redis_client = None + + @property + def redis_client(self): + """延迟获取Redis客户端,确保在Redis初始化后获取""" + if self._redis_client is None: + from redis_client.redis import get_redis + self._redis_client = get_redis() + return self._redis_client + + async def record_failed_attempt(self, user_id: int) -> Tuple[int, bool]: + """ + 记录一次登录失败。 + + Args: + user_id: 用户ID + + Returns: + Tuple[int, bool]: (当前失败次数, 是否达到阈值) + """ + if not self.redis_client: + log.warning("Redis 不可用,跳过登录失败计数") + return 0, False + + try: + from redis_client.redis_keys import redis_key_manager + key = redis_key_manager.get_login_fail_count_key(user_id) + + # 增加计数 (aioredis 是异步的,需要 await) + count = await self.redis_client.incr(key) + + # 第一次失败时设置过期时间 + if count == 1: + await self.redis_client.expire(key, self.EXPIRE_SECONDS) + + # 判断是否达到阈值 + should_suspend = count >= self.MAX_FAILED_ATTEMPTS + + if should_suspend: + log.warning("用户 {} 连续 {} 次输错密码,将被标记为异常", user_id, count) + else: + log.info("用户 {} 登录失败,当前失败次数: {}/{}", user_id, count, self.MAX_FAILED_ATTEMPTS) + + return count, should_suspend + + except Exception as e: + log.error("记录登录失败次数失败: {}", e) + return 0, False + + async def get_failed_count(self, user_id: int) -> int: + """ + 获取用户当前的登录失败次数。 + + Args: + user_id: 用户ID + + Returns: + int: 当前失败次数 + """ + if not self.redis_client: + return 0 + + try: + from redis_client.redis_keys import redis_key_manager + key = redis_key_manager.get_login_fail_count_key(user_id) + count = await self.redis_client.get(key) + return int(count) if count else 0 + except Exception as e: + log.error("获取登录失败次数失败: {}", e) + return 0 + + async def reset_failed_count(self, user_id: int) -> bool: + """ + 重置用户的登录失败计数(登录成功后调用)。 + + Args: + user_id: 用户ID + + Returns: + bool: 是否成功重置 + """ + if not self.redis_client: + return False + + try: + from redis_client.redis_keys import redis_key_manager + key = redis_key_manager.get_login_fail_count_key(user_id) + await self.redis_client.delete(key) + log.info("已重置用户 {} 的登录失败计数", user_id) + return True + except Exception as e: + log.error("重置登录失败计数失败: {}", e) + return False + + +# ============================================================================ +# 2. 异地频繁登录检测器 +# ============================================================================ + +class RemoteLoginDetector: + """ + 异地频繁登录检测器。 + + 注意:异地频繁登录检测(基于地理位置和距离)已禁用。 + 仅保留IP频繁变更检测功能。 + """ + + # IP变更检测参数 + IP_CHANGE_WINDOW = 1800 # 30分钟 + IP_CHANGE_THRESHOLD = 4 # 阈值4次 + + @staticmethod + async def clear_ip_history(user_id: int) -> bool: + """ + 清除用户的 IP 变更历史记录。 + + Args: + user_id: 用户ID + + Returns: + bool: 是否成功清除 + """ + try: + from redis_client.redis import get_redis + redis = get_redis() + if not redis: + return False + + ip_history_key = f"user:{user_id}:ip_history_zset" + + await redis.delete(ip_history_key) + + log.info(f"已清除用户 {user_id} 的 IP 变更历史记录") + return True + except Exception as e: + log.error(f"清除用户 IP 历史记录失败: {e}") + return False + + @staticmethod + async def check_frequent_ip_changes( + user_id: int, + current_ip: str + ) -> tuple[bool, Optional[str]]: + """ + 检测用户是否在短时间内频繁变更IP。 + + 逻辑:统计 30 分钟内出现的不同 IP 数量。 + + Args: + user_id: 用户ID + current_ip: 当前IP + + Returns: + Tuple[bool, Optional[str]]: (是否频繁变更, 描述信息) + """ + try: + from redis_client.redis import get_redis + redis = get_redis() + if not redis: + log.warning("Redis 不可用,跳过IP变更检测") + return False, None + + # 使用 Redis ZSET 存储 IP 历史,Member 为 IP,Score 为最后出现的时间戳 + ip_history_key = f"user:{user_id}:ip_history_zset" + current_timestamp = int(datetime.now(timezone.utc).timestamp()) + window_start = current_timestamp - RemoteLoginDetector.IP_CHANGE_WINDOW + + # 1. 添加/更新当前 IP 的时间戳 + await redis.zadd(ip_history_key, {current_ip: current_timestamp}) + + # 2. 移除 30 分钟之前的过期记录 + await redis.zremrangebyscore(ip_history_key, "-inf", window_start) + + # 3. 设置过期时间,避免永久占用 + await redis.expire(ip_history_key, RemoteLoginDetector.IP_CHANGE_WINDOW + 60) + + # 4. 统计当前窗口内的不同 IP 数量 + unique_ip_count = await redis.zcard(ip_history_key) + + if unique_ip_count >= RemoteLoginDetector.IP_CHANGE_THRESHOLD: + description = f"IP频繁变更检测: 30分钟内出现 {unique_ip_count} 个不同IP (阈值 {RemoteLoginDetector.IP_CHANGE_THRESHOLD})" + log.warning(f"用户 {user_id}: {description}") + return True, description + + return False, None + + except Exception as e: + log.error(f"IP变更检测失败: {e}") + return False, None + + except Exception as e: + log.error(f"IP变更检测失败: {e}") + return False, None + + @staticmethod + async def detect_remote_login( + db: AsyncSession, + user_id: int, + current_ip: str, + current_time: Optional[datetime] = None + ) -> tuple[bool, Optional[str]]: + """ + 检测当前登录是否为异地频繁登录。 + + 注意:异地频繁登录检测已禁用,始终返回 False。 + + Args: + db: 数据库会话 + user_id: 用户ID + current_ip: 当前登录IP + current_time: 当前时间(默认为now) + + Returns: + Tuple[bool, Optional[str]]: (是否检测到异地频繁登录, 检测信息描述) + """ + # 异地频繁登录检测已禁用 + return False, None + + +# ============================================================================ +# 3. 违规日志记录器 +# ============================================================================ + +class ViolationLogger: + """ + 违规日志记录器。 + + 负责记录用户的违规行为(频率超限、多次登录失败、异地频繁登录)。 + """ + + # 违规事件类型常量 + EVENT_EXCESSIVE_API_USAGE = "excessive_api_usage" + EVENT_MULTIPLE_FAILED_LOGINS = "multiple_failed_logins" + EVENT_FREQUENT_REMOTE_LOGIN = "frequent_remote_login" + + # 风险等级常量 + RISK_LOW = "LOW" + RISK_MEDIUM = "MEDIUM" + RISK_HIGH = "HIGH" + RISK_CRITICAL = "CRITICAL" + + @staticmethod + async def log_violation( + db: AsyncSession, + user_id: int, + event_type: str, + event_description: str, + risk_level: str = "MEDIUM", + ip_address: str = "127.0.0.1", + client_user_agent: Optional[str] = None, + request_content: Optional[str] = None, + auto_check_suspend: bool = True, + ) -> ViolationLog: + """ + 记录一条违规日志。 + + Args: + db: 数据库会话 + user_id: 违规用户ID + event_type: 事件类型 + event_description: 事件描述 + risk_level: 风险等级(默认 MEDIUM) + ip_address: IP 地址 + client_user_agent: 客户端信息 + request_content: 原始请求内容 + auto_check_suspend: 是否自动检查并标记用户为异常(默认 True) + 如果调用方已经手动处理了标记逻辑,应设为 False + + Returns: + ViolationLog: 创建的违规日志记录 + """ + try: + violation = ViolationLog( + user_id=user_id, + event_type=event_type, + event_description=event_description, + risk_level=risk_level, + ip_address=ip_address, + client_user_agent=client_user_agent, + request_content=request_content, + resolution_status="pending" # 初始状态为未处理 + ) + + db.add(violation) + await db.flush() # 立即写入数据库,以便获取 violation_id + + log.warning( + f"记录违规日志: 用户={user_id}, 类型={event_type}, " + f"风险={risk_level}, ID={violation.violation_id}" + ) + + # 任何违规记录都直接标记用户为异常(仅当 auto_check_suspend=True 时执行) + if auto_check_suspend: + from core.security import BlacklistManager + reason = f"触发违规记录: {event_type} - {event_description}" + success = await BlacklistManager.suspend_user(db, user_id, reason=reason) + if success: + log.warning(f"用户 {user_id} 因违规行为被系统自动标记为异常: {reason}") + + return violation + + except Exception as e: + log.error("记录违规日志失败: {}", e) + raise + + @staticmethod + async def get_violation_count( + db: AsyncSession, + user_id: int, + time_hours: int = 24, + event_types: Optional[list] = None + ) -> int: + """ + 获取用户在指定时间段内的违规记录数。 + + Args: + db: 数据库会话 + user_id: 用户ID + time_hours: 时间范围(小时),默认 24 小时 + event_types: 指定事件类型列表,如果为 None 则统计所有类型 + + Returns: + int: 违规记录数 + """ + try: + # 计算时间范围 + from sqlalchemy import and_ + from datetime import timedelta + + cutoff_time = datetime.now(timezone.utc) - timedelta(hours=time_hours) + + query = select(ViolationLog).where( + and_( + ViolationLog.user_id == user_id, + ViolationLog.created_at >= cutoff_time, + ViolationLog.resolution_status == "pending" + ) + ) + + # 如果指定了事件类型,额外过滤 + if event_types: + query = query.where(ViolationLog.event_type.in_(event_types)) + + result = await db.execute(query) + violations = result.scalars().all() + + return len(violations) + + except Exception as e: + log.error("查询违规记录失败: {}", e) + return 0 + + +# ============================================================================ +# 3. 黑名单管理器 +# ============================================================================ + +class BlacklistManager: + """ + 黑名单管理器。 + + 负责自动检测违规用户并将其标记为异常,以及管理员的黑名单操作。 + """ + + @staticmethod + async def suspend_user( + db: AsyncSession, + user_id: int, + reason: str = "系统检测到异常行为" + ) -> bool: + """ + 将用户标记为异常状态(suspended)。 + + 注意:这是系统自动操作,不是真正的封禁。用户可以联系管理员申诉。 + 管理员账户不会被标记为异常。 + Args: + db: 数据库会话 + user_id: 用户ID + reason: 异常原因 + + Returns: + bool: 是否成功标记 + """ + try: + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == user_id) + ) + user = result.scalar_one_or_none() + + if not user: + log.error("用户 {} 不存在", user_id) + return False + + # 管理员不会被标记为异常 + if user.is_admin: + log.info("用户 {} 是管理员,跳过自动标记", user_id) + return False + + # 只有 normal 状态的用户才能被标记为 suspended + if user.status != 'normal': + log.info("用户 {} 状态为 {},跳过自动标记", user_id, user.status) + return False + + user.status = 'suspended' + await db.commit() + + log.warning("用户 {} 已被系统标记为异常: {}", user_id, reason) + return True + + except Exception as e: + log.error("标记用户异常失败: {}", e) + await db.rollback() + return False + + @staticmethod + async def ban_user( + db: AsyncSession, + user_id: int, + reason: str = "违反服务条款", + banned_by: int = 0 # 管理员ID,0 表示系统 + ) -> bool: + """ + 管理员手动封禁用户(设置 status='banned')。 + + 注意:只有管理员才能执行真正的封禁操作。 + + Args: + db: 数据库会话 + user_id: 要封禁的用户ID + reason: 封禁原因 + banned_by: 执行封禁的管理员ID + + Returns: + bool: 是否成功封禁 + """ + try: + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == user_id) + ) + user = result.scalar_one_or_none() + + if not user: + log.error("用户 {} 不存在", user_id) + return False + + # 更新用户状态为 banned + user.status = 'banned' + + await db.commit() + + # 清除 IP 变更历史记录 + from core.security import RemoteLoginDetector + await RemoteLoginDetector.clear_ip_history(user_id) + + # 在封禁用户后,强制登出用户的所有活跃会话 + # 导入用户服务以强制登出用户的所有会话 + from service.user_service import force_logout_user_sessions + await force_logout_user_sessions(user_id) + + log.warning("管理员 {} 封禁了用户 {}: {}", banned_by, user_id, reason) + return True + + except Exception as e: + log.error("封禁用户失败: {}", e) + await db.rollback() + return False + + @staticmethod + async def unban_user( + db: AsyncSession, + user_id: int, + admin_id: int = 0 + ) -> bool: + """ + 管理员解封用户(设置 status='normal')。 + + 可以解除 banned 和 suspended 状态。 + + Args: + db: 数据库会话 + user_id: 要解封的用户ID + admin_id: 执行解封的管理员ID + + Returns: + bool: 是否成功解封 + """ + try: + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == user_id) + ) + user = result.scalar_one_or_none() + + if not user: + log.error("用户 {} 不存在", user_id) + return False + + if user.status == 'normal': + log.info("用户 {} 状态已经是 normal", user_id) + return True + + # 更新用户状态为 normal + old_status = user.status + user.status = 'normal' + + await db.commit() + + # 清除 IP 变更历史记录 + from core.security import RemoteLoginDetector + await RemoteLoginDetector.clear_ip_history(user_id) + + log.info("管理员 {} 解封了用户 {} (原状态: {})", admin_id, user_id, old_status) + return True + + except Exception as e: + log.error("解封用户失败: {}", e) + await db.rollback() + return False + + @staticmethod + async def get_banned_users(db: AsyncSession, limit: int = 100) -> list: + """ + 获取所有被封禁的用户列表(status='banned')。 + + Args: + db: 数据库会话 + limit: 返回的最大记录数 + + Returns: + list: 被封禁的用户列表 + """ + try: + result = await db.execute( + select(UserAccount) + .where(UserAccount.status == 'banned') + .limit(limit) + ) + users = result.scalars().all() + return users + + except Exception as e: + log.error("获取被封禁用户列表失败: {}", e) + return [] + + @staticmethod + async def get_suspended_users(db: AsyncSession, limit: int = 100) -> list: + """ + 获取所有被系统标记为异常的用户列表(status='suspended')。 + + Args: + db: 数据库会话 + limit: 返回的最大记录数 + + Returns: + list: 异常用户列表 + """ + try: + result = await db.execute( + select(UserAccount) + .where(UserAccount.status == 'suspended') + .limit(limit) + ) + users = result.scalars().all() + return users + + except Exception as e: + log.error("获取异常用户列表失败: {}", e) + return [] + + +# ============================================================================ +# 4. 全局实例 +# ============================================================================ + +# 创建全局的 Redis 频率限制器实例 +freq_limiter = RedisFrequencyLimiter() + +# 创建全局的登录失败追踪器实例 +login_failure_tracker = LoginFailureTracker() diff --git a/src/backend/app/core/sql_dialect_converter.py b/src/backend/app/core/sql_dialect_converter.py new file mode 100644 index 0000000..8ba937a --- /dev/null +++ b/src/backend/app/core/sql_dialect_converter.py @@ -0,0 +1,208 @@ +""" +SQL方言转换工具类。 + +支持多种数据库方言之间的SQL语句转换。 +""" + +# backend/app/core/sql_dialect_converter.py + +from typing import Optional, Dict, Any +try: + import sqlglot + from sqlglot import errors as sqlglot_errors + SQLGLOT_AVAILABLE = True +except ImportError: + SQLGLOT_AVAILABLE = False + sqlglot = None + sqlglot_errors = None + +class SQLDialectConverter: + """ + SQL方言转换器。 + + 支持在不同数据库方言之间转换SQL语句。 + + Attributes: + SUPPORTED_DIALECTS (Dict[str, str]): 支持的数据库方言映射表。 + """ + + # 支持的数据库方言映射 + SUPPORTED_DIALECTS = { + 'mysql': 'mysql', + 'postgresql': 'postgres', + 'postgres': 'postgres', + 'sqlite': 'sqlite', + 'sqlserver': 'tsql', + 'tsql': 'tsql', + 'oracle': 'oracle', + 'bigquery': 'bigquery', + 'snowflake': 'snowflake', + 'redshift': 'redshift', + 'presto': 'presto', + 'trino': 'trino', + 'hive': 'hive', + 'spark': 'spark', + 'duckdb': 'duckdb' + } if SQLGLOT_AVAILABLE else {} + + def __init__(self): + """ + 初始化 SQL 方言转换器。 + + Raises: + ImportError: 如果 sqlglot 库未安装。 + """ + if not SQLGLOT_AVAILABLE: + raise ImportError( + "sqlglot库未安装。请运行 'pip install sqlglot' 安装后再使用此功能。" + ) + + def convert(self, sql: str, source_dialect: str, target_dialect: str, + pretty: bool = False) -> str: + """ + 将SQL语句从源方言转换为目标方言。 + + Args: + sql (str): 要转换的SQL语句。 + source_dialect (str): 源数据库方言。 + target_dialect (str): 目标数据库方言。 + pretty (bool): 是否美化输出。 + + Returns: + str: 转换后的SQL语句。 + + Raises: + ValueError: 当指定的方言不支持时。 + SQLConversionError: 当SQL转换失败时。 + """ + # 验证方言是否支持 + if source_dialect not in self.SUPPORTED_DIALECTS: + raise ValueError(f"不支持的源方言: {source_dialect}") + + if target_dialect not in self.SUPPORTED_DIALECTS: + raise ValueError(f"不支持的目标方言: {target_dialect}") + + try: + # 执行转换 + result = sqlglot.transpile( + sql, + read=self.SUPPORTED_DIALECTS[source_dialect], + write=self.SUPPORTED_DIALECTS[target_dialect], + pretty=pretty + ) + return result[0] if result else sql + except sqlglot_errors.ParseError as e: + raise SQLConversionError(f"SQL解析错误: {str(e)}") + except Exception as e: + raise SQLConversionError(f"转换过程中发生错误: {str(e)}") + + def validate_sql(self, sql: str, dialect: str = 'mysql') -> bool: + """ + 验证SQL语法是否正确。 + + Args: + sql (str): 要验证的SQL语句。 + dialect (str): SQL方言,默认为MySQL。 + + Returns: + bool: 如果SQL语法正确返回True,否则返回False。 + + Raises: + ValueError: 当指定的方言不支持时。 + """ + if dialect not in self.SUPPORTED_DIALECTS: + raise ValueError(f"不支持的方言: {dialect}") + + try: + sqlglot.parse(sql, read=self.SUPPORTED_DIALECTS[dialect]) + return True + except sqlglot_errors.ParseError: + return False + except Exception: + return False + + def format_sql(self, sql: str, dialect: str = 'mysql', pretty: bool = True) -> str: + """ + 格式化SQL语句。 + + Args: + sql (str): 要格式化的SQL语句。 + dialect (str): SQL方言,默认为MySQL。 + pretty (bool): 是否美化输出。 + + Returns: + str: 格式化后的SQL语句。 + + Raises: + ValueError: 当指定的方言不支持时。 + SQLConversionError: 当格式化失败时。 + """ + if dialect not in self.SUPPORTED_DIALECTS: + raise ValueError(f"不支持的方言: {dialect}") + + try: + result = sqlglot.transpile( + sql, + read=self.SUPPORTED_DIALECTS[dialect], + write=self.SUPPORTED_DIALECTS[dialect], + pretty=pretty + ) + return result[0] if result else sql + except sqlglot_errors.ParseError as e: + raise SQLConversionError(f"SQL解析错误: {str(e)}") + except Exception as e: + raise SQLConversionError(f"格式化过程中发生错误: {str(e)}") + + def get_supported_dialects(self) -> Dict[str, str]: + """ + 获取支持的方言列表。 + + Returns: + Dict[str, str]: 支持的方言字典。 + """ + return self.SUPPORTED_DIALECTS.copy() + + +class SQLConversionError(Exception): + """SQL转换异常""" + pass + + +# 使用示例 +if __name__ == "__main__": + # 示例:在不同数据库之间转换SQL + converter = SQLDialectConverter() + + # # MySQL到PostgreSQL的转换示例 + # mysql_sql = "SELECT DATE_FORMAT(NOW(), '%Y-%m-%d') AS today" + # try: + # postgresql_sql = converter.convert(mysql_sql, 'mysql', 'postgresql') + # print(f"MySQL: {mysql_sql}") + # print(f"PostgreSQL: {postgresql_sql}") + # except SQLConversionError as e: + # print(f"转换失败: {e}") + + # PostgreSQL到MySQL的转换示例 + postgresql_sqls = """CREATE TABLE Hotel (Name TEXT NOT NULL, Address TEXT, Phone TEXT, PRIMARY KEY(Name)); +CREATE TABLE Room (Type TEXT NOT NULL, Price NUMERIC, Quantity NUMERIC, PRIMARY KEY(Type)); +CREATE TABLE Booking (BookingID TEXT NOT NULL, RoomType TEXT NOT NULL, StartDate DATETIME, EndDate DATETIME, PRIMARY KEY(BookingID), FOREIGN KEY(RoomType) REFERENCES Room(Type)); +CREATE TABLE User (UserID TEXT NOT NULL, Name TEXT, Contact TEXT, Discount NUMERIC, CreditCard TEXT, PRIMARY KEY(UserID)); +CREATE TABLE Booking_Room (BookingID TEXT NOT NULL, RoomType TEXT NOT NULL, Duration NUMERIC, RoomPreference TEXT, PRIMARY KEY(BookingID, RoomType), FOREIGN KEY(BookingID) REFERENCES Booking(BookingID), FOREIGN KEY(RoomType) REFERENCES Room(Type)); +CREATE TABLE User_Booking (UserID TEXT NOT NULL, BookingID TEXT NOT NULL, PaymentMethod TEXT, LoyaltyProgram TEXT, PRIMARY KEY(UserID, BookingID), FOREIGN KEY(UserID) REFERENCES User(UserID), FOREIGN KEY(BookingID) REFERENCES Booking(BookingID));""" + try: + for sql in postgresql_sqls.split(';'): + mysql = converter.convert(sql, 'sqlite', 'mysql') + print(f"PostgreSQL: {sql}") + print(f"MySQL: {mysql}") + except SQLConversionError as e: + print(f"转换失败: {e}") + + # # SQL验证示例 + # invalid_sql = "SELECT * FROM users WHERE" + # is_valid = converter.validate_sql(invalid_sql, 'mysql') + # print(f"SQL '{invalid_sql}' 有效性: {is_valid}") + # + # # SQL格式化示例 + # unformatted_sql = "SELECT id,name,email FROM users WHERE age>18 ORDER BY name" + # formatted_sql = converter.format_sql(unformatted_sql, 'mysql') + # print(f"格式化后: {formatted_sql}") diff --git a/src/backend/app/core/sql_sort.py b/src/backend/app/core/sql_sort.py new file mode 100644 index 0000000..96ea35e --- /dev/null +++ b/src/backend/app/core/sql_sort.py @@ -0,0 +1,162 @@ +""" +SQL DDL 排序工具模块。 + +提供基于依赖关系的 DDL 语句排序功能,解决外键约束导致的执行顺序问题。 +""" + +# backend/app/core/sql_sort.py + +import sqlglot +import sqlglot.expressions as exp +from collections import deque +from typing import List, Set, Dict, Tuple, Optional + + +def sort_ddl_by_dependency(ddl_text: str, dialect: str = "mysql") -> List[str]: + """ + 自动化排序 DDL 语句,支持 MySQL、PostgreSQL、SQLite。 + + 解析 DDL 语句,分析表之间的依赖关系(如外键),并进行拓扑排序。 + 适配 sqlglot 24.0.0 版本。 + + Args: + ddl_text (str): 原始 DDL 字符串,包含多条语句。 + dialect (str): 数据库方言,可选:mysql/postgresql/sqlite。 + + Returns: + List[str]: 排序后的 DDL 语句列表。 + + Raises: + ValueError: 当检测到循环依赖、重复创建实体或 SQL 解析失败时抛出。 + """ + if not ddl_text.strip(): + return [] + + try: + parsed = sqlglot.parse(ddl_text, read=dialect) + except Exception as e: + raise ValueError(f"SQL parsing failed with dialect '{dialect}': {str(e)}") from e + + statements_ast = [stmt for stmt in parsed if stmt is not None] + if not statements_ast: + return [] + + # 辅助函数:获取外键引用的表名 + def get_foreign_key_ref_table(constraint: exp.ForeignKey) -> Optional[str]: + reference = constraint.args.get("reference") + if not (reference and isinstance(reference, exp.Reference)): + return None + + table_expr = reference.args.get("this") + if not (table_expr and isinstance(table_expr, exp.Table)): + return None + + return table_expr.name + + # 收集每个语句的元数据 + statements_info = [] + for stmt_ast in statements_ast: + info = { + "ast": stmt_ast, + "text": stmt_ast.sql(dialect=dialect, pretty=False), + "created": set(), # 本语句创建的实体名 + "depends": set(), # 本语句依赖的外部实体名 + } + + # 处理 CREATE 语句 + if isinstance(stmt_ast, exp.Create): + kind = stmt_ast.args.get("kind", "").upper() + + if kind == "TABLE": + if isinstance(stmt_ast.this, exp.Table): + table_name = stmt_ast.this.name + info["created"].add(table_name) + + # 检查外键约束 + if isinstance(stmt_ast.expression, exp.Schema): + for constraint in stmt_ast.expression.constraints: + if isinstance(constraint, exp.ForeignKey): + ref_table = get_foreign_key_ref_table(constraint) + if ref_table: + info["depends"].add(ref_table) + + elif kind == "VIEW": + if isinstance(stmt_ast.this, exp.Table): + view_name = stmt_ast.this.name + info["created"].add(view_name) + + # 提取视图依赖的表 + query = stmt_ast.expression + if query: + for table_node in query.find_all(exp.Table): + info["depends"].add(table_node.name) + + elif kind == "INDEX": + # 索引依赖所在表 + if isinstance(stmt_ast.this, exp.Table): + info["depends"].add(stmt_ast.this.name) + + # 处理 ALTER TABLE 语句 + elif isinstance(stmt_ast, exp.Alter) and stmt_ast.args.get("kind") == "TABLE": + if isinstance(stmt_ast.this, exp.Table): + table_name = stmt_ast.this.name + info["depends"].add(table_name) + + # 检查添加外键的操作 + for action in stmt_ast.args.get("actions", []): + if isinstance(action, exp.AddConstraint): + constraint = action.args.get("constraint") + if isinstance(constraint, exp.ForeignKey): + ref_table = get_foreign_key_ref_table(constraint) + if ref_table: + info["depends"].add(ref_table) + + statements_info.append(info) + + # 构建实体创建映射: 实体名 -> 语句索引 + created_entities: Dict[str, int] = {} + for idx, info in enumerate(statements_info): + for entity in info["created"]: + if entity in created_entities: + prev_idx = created_entities[entity] + raise ValueError( + f"Entity '{entity}' is created by multiple statements " + f"(statement {prev_idx + 1} and {idx + 1})" + ) + created_entities[entity] = idx + + # 构建依赖图 + n = len(statements_info) + graph: List[List[int]] = [[] for _ in range(n)] + indegree = [0] * n + + for idx, info in enumerate(statements_info): + for dep_entity in info["depends"]: + creator_idx = created_entities.get(dep_entity) + if creator_idx is not None and creator_idx != idx: + graph[creator_idx].append(idx) + indegree[idx] += 1 + + # 拓扑排序 (Kahn's algorithm) + queue = deque([i for i in range(n) if indegree[i] == 0]) + queue = deque(sorted(queue)) # 保持原始顺序 + sorted_indices = [] + + while queue: + node = queue.popleft() + sorted_indices.append(node) + + for neighbor in graph[node]: + indegree[neighbor] -= 1 + if indegree[neighbor] == 0: + queue.append(neighbor) + + # 保持队列顺序 + queue = deque(sorted(queue)) + + # 检测循环依赖 + if len(sorted_indices) != n: + raise ValueError("Circular dependency detected in DDL statements") + + # 生成排序后的 DDL 语句列表 + return [statements_info[i]["text"] for i in sorted_indices] diff --git a/src/backend/app/core/utils.py b/src/backend/app/core/utils.py new file mode 100644 index 0000000..115c33b --- /dev/null +++ b/src/backend/app/core/utils.py @@ -0,0 +1,95 @@ +""" +通用工具函数模块。 + +包含从业务代码中抽离的通用工具函数,遵循单一职责原则。 +""" + +import re +import random +import string +from pypinyin import lazy_pinyin, Style + + +def generate_meaningful_db_name(project_name: str, user_id: int) -> str: + """ + 根据项目名称和用户 ID 生成符合数据库命名规范的唯一数据库名。 + + 将项目名称转换为符合数据库命名规范的字符串 (拼音/英文 + 下划线) + 例如: "电商管理平台" -> "dianshang_guanli_pingtai_1_x82a" + + Args: + project_name (str): 项目名称。 + user_id (int): 用户 ID。 + + Returns: + str: 生成的数据库名称。 + """ + # 1. 中文转拼音,英文单词保持不变 + pinyin_list = lazy_pinyin(project_name, style=Style.NORMAL) + + # 2. 拼接成字符串 + full_str = "_".join(pinyin_list) + + # 3. 清洗:只保留字母、数字和下划线,且转为小写 + clean_str = re.sub(r'[^a-zA-Z0-9_]', '_', full_str).lower() + + # 4. 去除连续的下划线 + clean_str = re.sub(r'_+', '_', clean_str).strip('_') + + # 5. 截断过长的前缀 (防止数据库名过长,保留前 40 字符) + if len(clean_str) > 40: + clean_str = clean_str[:40].rstrip('_') + + # 6. 加上 user_id 和短随机码 (确保全局唯一性) + short_random = ''.join(random.choices(string.ascii_lowercase + string.digits, k=4)) + + return f"{clean_str}_{user_id}_{short_random}" + + +def generate_random_string(length: int = 8, include_digits: bool = True) -> str: + """ + 生成随机字符串。 + + Args: + length: 字符串长度 + include_digits: 是否包含数字 + + Returns: + str: 随机字符串 + """ + chars = string.ascii_lowercase + if include_digits: + chars += string.digits + return ''.join(random.choices(chars, k=length)) + + +def get_client_ip(request) -> str: + """ + 从 HTTP 请求中获取真实的客户端 IP 地址。 + + 在反向代理(如 Nginx、Docker 网络)环境下,request.client.host 只能获取到代理服务器的 IP。 + 此函数优先从 X-Forwarded-For 或 X-Real-IP 头中获取真实客户端 IP。 + + Args: + request: FastAPI/Starlette Request 对象 + + Returns: + str: 客户端真实 IP 地址 + """ + # 优先从 X-Forwarded-For 获取(可能包含多个 IP,取第一个) + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + # X-Forwarded-For 格式: client, proxy1, proxy2, ... + # 取第一个即为原始客户端 IP + return forwarded_for.split(",")[0].strip() + + # 其次从 X-Real-IP 获取 + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip.strip() + + # 最后回退到 request.client.host + if request.client: + return request.client.host + + return "127.0.0.1" diff --git a/src/backend/app/crud/__init__.py b/src/backend/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/app/crud/crud_admin_data.py b/src/backend/app/crud/crud_admin_data.py new file mode 100644 index 0000000..ed3ea44 --- /dev/null +++ b/src/backend/app/crud/crud_admin_data.py @@ -0,0 +1,341 @@ +""" +管理端数据 CRUD。 + +本模块提供管理端数据的 CRUD 操作,包括用户管理、系统统计和违规日志。 +""" + +# backend/app/crud/crud_admin_data.py + +from typing import List, Literal, Tuple, Dict, Any, Optional +from datetime import datetime, date +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import func, or_, desc, cast, Date, and_ + +from core.exceptions import DatabaseOperationFailedException + +# 导入所有需要的模型 +from models.user_account import UserAccount +from models.project import Project +from models.violation_log import ViolationLog +from models.user_login_history import UserLoginHistory +from models.ai_generated_statement import AIGeneratedStatement + + +class CRUDAdminData: + """ + 管理员数据操作类。 + + 提供管理员专属的复杂查询功能,包括用户统计、系统统计、违规日志查询等。 + """ + + # ------------------------------------------------------------------ + # 4.1.1. 获取用户列表 (带统计数据) + # ------------------------------------------------------------------ + @staticmethod + async def get_user_list_with_stats( + db: AsyncSession, + page: int, + page_size: int, + search: Optional[str] = None, + status: Literal["normal", "suspended", "banned", "all"] = "all" + ) -> Tuple[List[Dict[str, Any]], int]: + """ + 获取用户列表,包含项目统计和额度。 + + 实现逻辑:查询 UserAccount 表,并 Left Join Project 表计算 count。 + + Args: + db (AsyncSession): 数据库会话。 + page (int): 页码。 + page_size (int): 每页数量。 + search (Optional[str]): 搜索关键字(用户名或邮箱)。 + status (Literal["normal", "suspended", "banned", "all"]): 用户状态筛选。 + + Returns: + Tuple[List[Dict[str, Any]], int]: (用户列表, 总记录数)。 + 用户列表中的每个字典包含用户基本信息和 'project_count'。 + + Raises: + DatabaseOperationFailedException: 数据库查询失败时抛出。 + """ + try: + # 1. 构建基础查询:选择用户列 + 项目计数(排除已删除项目) + # SELECT u.*, count(p.project_id) FROM user_account u LEFT JOIN project p ON ... + stmt = ( + select( + UserAccount, + func.count(Project.project_id).label("project_count") + ) + .outerjoin( + Project, + and_( + UserAccount.user_id == Project.user_id, + Project.project_status != 'deleted' + ) + ) + .group_by(UserAccount.user_id) + ) + + # 2. 构建 Count 查询 (用于分页总数) + count_stmt = select(func.count(UserAccount.user_id)) + + # 3. 应用筛选条件 + filters = [] + # 需求:管理员“用户列表”只展示普通用户(不包含管理员账号) + filters.append(UserAccount.is_admin == False) + if status != "all": + filters.append(UserAccount.status == status) + + if search: + search_term = f"%{search}%" + filters.append( + or_( + UserAccount.username.ilike(search_term), + UserAccount.email.ilike(search_term) + ) + ) + + if filters: + stmt = stmt.where(*filters) + count_stmt = count_stmt.where(*filters) + + # 4. 执行总数查询 + total = await db.scalar(count_stmt) or 0 + + # 5. 执行分页查询 (按注册时间倒序) + stmt = stmt.order_by(desc(UserAccount.created_at)).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(stmt) + rows = result.all() + + # 6. 组装返回数据 (将 ORM 对象转为字典,并合并且它的统计数据) + items = [] + for user, project_count in rows: + items.append({ + "user_id": user.user_id, + "username": user.username, + "email": user.email, + "status": user.status, + "max_databases": user.max_databases, + "last_login_at": user.last_login_at, + "avatar_url": user.avatar_url, + "project_count": project_count # 聚合查询出来的字段 + }) + + return items, total + + except Exception as e: + raise DatabaseOperationFailedException("fetch user list with stats") from e + + # ------------------------------------------------------------------ + # 4.3.1. 获取管理员列表 + # ------------------------------------------------------------------ + @staticmethod + async def get_admin_list(db: AsyncSession, page: int, page_size: int) -> Tuple[List[UserAccount], int]: + """ + 获取所有管理员列表。 + + Args: + db (AsyncSession): 数据库会话。 + page (int): 页码。 + page_size (int): 每页数量。 + + Returns: + Tuple[List[UserAccount], int]: (管理员列表, 总记录数)。 + + Raises: + DatabaseOperationFailedException: 数据库查询失败时抛出。 + """ + try: + # 1. 筛选条件 + query = select(UserAccount).where(UserAccount.is_admin == True) + count_query = select(func.count(UserAccount.user_id)).where(UserAccount.is_admin == True) + + # 2. 获取总数 + total = await db.scalar(count_query) or 0 + + # 3. 分页查询 + query = query.order_by(desc(UserAccount.created_at)).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + admins = result.scalars().all() + + return admins, total + except Exception as e: + raise DatabaseOperationFailedException("fetch admin list") from e + + # ------------------------------------------------------------------ + # 4.4.1. 获取系统统计 + # ------------------------------------------------------------------ + @staticmethod + async def get_system_stats(db: AsyncSession) -> Dict[str, Any]: + """ + 获取系统统计看板数据 (真实数据库聚合)。 + + 统计今日活跃用户、项目总数、今日查询数、今日高风险操作等指标。 + + Args: + db (AsyncSession): 数据库会话。 + + Returns: + Dict[str, Any]: 包含各项统计数据的字典。 + + Raises: + DatabaseOperationFailedException: 数据库查询失败时抛出。 + """ + try: + today = datetime.now().date() + + # 1. 今日活跃用户 (今日有登录记录的去重用户数) + # 注意:SQLAlchemy 中 date 类型转换通常用 cast(col, Date) + active_users_query = select(func.count(func.distinct(UserLoginHistory.user_id))) \ + .where(cast(UserLoginHistory.login_time, Date) == today) + active_users_today = await db.scalar(active_users_query) or 0 + + # 2. 项目总数(排除已删除项目,包括部署中和已部署完成的) + total_projects_query = select(func.count(Project.project_id)).where( + Project.project_status != 'deleted' + ) + total_projects = await db.scalar(total_projects_query) or 0 + + # 3. 今日查询数 (AI 生成语句的数量作为近似值) + query_count_query = select(func.count(AIGeneratedStatement.statement_id)) \ + .where(cast(AIGeneratedStatement.created_at, Date) == today) + query_count_today = await db.scalar(query_count_query) or 0 + + # 4. 今日高风险操作 (ViolationLog 中 risk_level 为 HIGH 或 CRITICAL) + high_risk_query = select(func.count(ViolationLog.violation_id)) \ + .where( + cast(ViolationLog.created_at, Date) == today, + ViolationLog.risk_level.in_(['HIGH', 'CRITICAL']) + ) + high_risk_ops = await db.scalar(high_risk_query) or 0 + + # 5. 计算系统健康度 (简单逻辑) + system_health = "good" + if high_risk_ops > 10: + system_health = "critical" + elif high_risk_ops > 0: + system_health = "warning" + + return { + "active_users_today": active_users_today, + "total_projects": total_projects, + "query_count_today": query_count_today, + "high_risk_operations_today": high_risk_ops, + "system_health": system_health + } + except Exception as e: + # 统计失败不应阻塞主流程,可以返回全0或抛出异常,这里选择抛出 + raise DatabaseOperationFailedException("fetch system stats") from e + + # ------------------------------------------------------------------ + # 4.4.2. 获取违规日志 + # ------------------------------------------------------------------ + @staticmethod + async def get_violation_logs( + db: AsyncSession, + page: int, + page_size: int, + risk_level: Optional[str] = None, + resolution_status: Optional[str] = None + ) -> Tuple[List[Dict[str, Any]], int]: + """ + 获取违规记录列表 (支持分页、筛选,并关联用户名)。 + + 注意:同一用户的同一违规类型只显示最新的一条记录。 + + Args: + db (AsyncSession): 数据库会话。 + page (int): 页码。 + page_size (int): 每页数量。 + risk_level (Optional[str]): 风险等级筛选。 + resolution_status (Optional[str]): 处理状态筛选。 + + Returns: + Tuple[List[Dict[str, Any]], int]: (违规记录列表, 总记录数)。 + + Raises: + DatabaseOperationFailedException: 数据库查询失败时抛出。 + """ + try: + from sqlalchemy import func, and_ + from sqlalchemy.sql import exists + + # 使用子查询找出每个用户每种违规类型的最新记录ID + # 子查询:获取每个(user_id, event_type)组合的最大violation_id + subquery = select( + ViolationLog.user_id, + ViolationLog.event_type, + func.max(ViolationLog.violation_id).label('max_id') + ).group_by( + ViolationLog.user_id, + ViolationLog.event_type + ).subquery() + + # 1. 构建基础查询:关联 UserAccount 表以获取 username + # 只选择在子查询中的记录(即每个用户每种类型的最新记录) + query = select(ViolationLog, UserAccount.username) \ + .join(UserAccount, ViolationLog.user_id == UserAccount.user_id) \ + .join( + subquery, + and_( + ViolationLog.user_id == subquery.c.user_id, + ViolationLog.event_type == subquery.c.event_type, + ViolationLog.violation_id == subquery.c.max_id + ) + ) + + # 2. 动态添加筛选条件 + filters = [] + if risk_level: + filters.append(ViolationLog.risk_level == risk_level) + if resolution_status: + filters.append(ViolationLog.resolution_status == resolution_status) + + if filters: + query = query.where(*filters) + + # 3. 执行总数查询(应用相同的去重逻辑) + count_query = select(func.count(ViolationLog.violation_id)) \ + .join( + subquery, + and_( + ViolationLog.user_id == subquery.c.user_id, + ViolationLog.event_type == subquery.c.event_type, + ViolationLog.violation_id == subquery.c.max_id + ) + ) + + if filters: + count_query = count_query.where(*filters) + + total = await db.scalar(count_query) or 0 + + # 4. 执行分页查询 (按时间倒序) + query = query.order_by(desc(ViolationLog.created_at)) \ + .offset((page - 1) * page_size) \ + .limit(page_size) + + result = await db.execute(query) + rows = result.all() + + # 5. 格式化返回数据 + items = [] + for log_obj, username in rows: + items.append({ + "violation_id": log_obj.violation_id, + "user_id": log_obj.user_id, + "username": username, + "event_type": log_obj.event_type, + "event_description": log_obj.event_description, + "risk_level": log_obj.risk_level, + "resolution_status": log_obj.resolution_status, + "created_at": log_obj.created_at + }) + + return items, total + except Exception as e: + raise DatabaseOperationFailedException("fetch violation logs") from e + + +crud_admin_data = CRUDAdminData() diff --git a/src/backend/app/crud/crud_ai_model_config.py b/src/backend/app/crud/crud_ai_model_config.py new file mode 100644 index 0000000..61c1a42 --- /dev/null +++ b/src/backend/app/crud/crud_ai_model_config.py @@ -0,0 +1,255 @@ +""" +AI 模型配置 CRUD(精简版)。 + +本模块提供 AI 模型配置的增删改查操作。 +""" + +# backend/app/crud/crud_ai_model_config.py + +from typing import Optional, List, Dict, Any +from sqlalchemy.future import select +from sqlalchemy import delete, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError + +from models.ai_model_config import AIModelConfig +from schema.ai_model_config import AIModelConfigCreate, AIModelConfigUpdate +from core.exceptions import DatabaseOperationFailedException +from core.log import log + + +class CRUDAIModelConfig: + """ + AI 模型配置数据操作类。 + + 提供 AI 模型配置的增删改查功能。 + """ + + @staticmethod + async def create(db: AsyncSession, config_in: AIModelConfigCreate) -> AIModelConfig: + """ + 创建新的 AI 模型配置。 + + Args: + db (AsyncSession): 数据库会话。 + config_in (AIModelConfigCreate): 创建请求数据。 + + Returns: + AIModelConfig: 创建的配置对象。 + + Raises: + DatabaseOperationFailedException: 如果发生数据库错误。 + """ + try: + db_obj = AIModelConfig( + model_name=config_in.model_name, + api_url=config_in.api_url, + model_id=config_in.model_id, + api_key=config_in.api_key, + model_type=config_in.model_type + ) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + log.info(f"Created AI model config: {config_in.model_name}") + return db_obj + except SQLAlchemyError as e: + await db.rollback() + log.error(f"Failed to create AI model config: {e}") + raise DatabaseOperationFailedException("create AI model config") from e + + @staticmethod + async def get(db: AsyncSession, config_id: int) -> Optional[AIModelConfig]: + """ + 根据配置ID获取 AI 模型配置。 + + Args: + db (AsyncSession): 数据库会话。 + config_id (int): 配置ID。 + + Returns: + Optional[AIModelConfig]: 配置对象或 None。 + """ + try: + query = select(AIModelConfig).where(AIModelConfig.config_id == config_id) + result = await db.execute(query) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + log.error(f"Failed to get AI model config by id: {e}") + raise DatabaseOperationFailedException("get AI model config") from e + + @staticmethod + async def get_by_name(db: AsyncSession, model_name: str) -> Optional[AIModelConfig]: + """ + 根据 model_name 获取 AI 模型配置。 + + Args: + db (AsyncSession): 数据库会话。 + model_name (str): 模型名称。 + + Returns: + Optional[AIModelConfig]: 配置对象或 None。 + """ + try: + query = select(AIModelConfig).where(AIModelConfig.model_name == model_name) + result = await db.execute(query) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + log.error(f"Failed to get AI model config by name: {e}") + raise DatabaseOperationFailedException("get AI model config by name") from e + + @staticmethod + async def get_all( + db: AsyncSession, + skip: int = 0, + limit: int = 100 + ) -> List[AIModelConfig]: + """ + 获取所有 AI 模型配置。 + + Args: + db (AsyncSession): 数据库会话。 + skip (int): 跳过记录数。 + limit (int): 返回记录数限制。 + + Returns: + List[AIModelConfig]: 配置列表。 + """ + try: + query = select(AIModelConfig).order_by(AIModelConfig.created_at.asc()) + query = query.offset(skip).limit(limit) + result = await db.execute(query) + return list(result.scalars().all()) + except SQLAlchemyError as e: + log.error(f"Failed to get AI model configs: {e}") + raise DatabaseOperationFailedException("get AI model configs") from e + + @staticmethod + async def get_count(db: AsyncSession) -> int: + """ + 获取配置总数。 + + Args: + db (AsyncSession): 数据库会话。 + + Returns: + int: 配置总数。 + """ + try: + from sqlalchemy import func + query = select(func.count(AIModelConfig.config_id)) + result = await db.execute(query) + return result.scalar() or 0 + except SQLAlchemyError as e: + log.error(f"Failed to count AI model configs: {e}") + raise DatabaseOperationFailedException("count AI model configs") from e + + @staticmethod + async def update( + db: AsyncSession, + config_id: int, + config_in: AIModelConfigUpdate + ) -> Optional[AIModelConfig]: + """ + 更新 AI 模型配置。 + + Args: + db (AsyncSession): 数据库会话。 + config_id (int): 配置ID。 + config_in (AIModelConfigUpdate): 更新数据。 + + Returns: + Optional[AIModelConfig]: 更新后的配置对象或 None。 + """ + try: + db_obj = await CRUDAIModelConfig.get(db, config_id) + if not db_obj: + return None + + update_data = config_in.model_dump(exclude_unset=True) + + for field, value in update_data.items(): + setattr(db_obj, field, value) + + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + log.info(f"Updated AI model config: {db_obj.model_name}") + return db_obj + except SQLAlchemyError as e: + await db.rollback() + log.error(f"Failed to update AI model config: {e}") + raise DatabaseOperationFailedException("update AI model config") from e + + @staticmethod + async def delete(db: AsyncSession, config_id: int) -> bool: + """ + 删除 AI 模型配置。 + + Args: + db (AsyncSession): 数据库会话。 + config_id (int): 配置ID。 + + Returns: + bool: 是否删除成功。 + """ + try: + db_obj = await CRUDAIModelConfig.get(db, config_id) + if not db_obj: + return False + + model_name = db_obj.model_name + await db.delete(db_obj) + await db.commit() + log.info(f"Deleted AI model config: {model_name}") + return True + except SQLAlchemyError as e: + await db.rollback() + log.error(f"Failed to delete AI model config: {e}") + raise DatabaseOperationFailedException("delete AI model config") from e + + @staticmethod + async def get_model_registry(db: AsyncSession) -> Dict[str, Dict[str, Any]]: + """ + 获取所有模型配置,返回 MODEL_REGISTRY 格式。 + + 用于兼容现有的 chat_service 代码。 + + Args: + db (AsyncSession): 数据库会话。 + + Returns: + Dict[str, Dict[str, Any]]: 模型配置字典,key 为 model_name。 + """ + configs = await CRUDAIModelConfig.get_all(db) + registry = {} + for config in configs: + registry[config.model_name] = config.to_registry_format() + return registry + + @staticmethod + async def check_name_exists(db: AsyncSession, model_name: str, exclude_id: int = None) -> bool: + """ + 检查 model_name 是否已存在。 + + Args: + db (AsyncSession): 数据库会话。 + model_name (str): 模型名称。 + exclude_id (int): 排除的配置ID(用于更新时检查)。 + + Returns: + bool: 是否存在。 + """ + try: + query = select(AIModelConfig).where(AIModelConfig.model_name == model_name) + if exclude_id: + query = query.where(AIModelConfig.config_id != exclude_id) + result = await db.execute(query) + return result.scalar_one_or_none() is not None + except SQLAlchemyError as e: + log.error(f"Failed to check model name existence: {e}") + raise DatabaseOperationFailedException("check model name existence") from e + + +# 单例导出 +crud_ai_model_config = CRUDAIModelConfig() diff --git a/src/backend/app/crud/crud_announcement.py b/src/backend/app/crud/crud_announcement.py new file mode 100644 index 0000000..c9f56a2 --- /dev/null +++ b/src/backend/app/crud/crud_announcement.py @@ -0,0 +1,255 @@ +""" +公告 CRUD。 + +本模块提供系统公告的 CRUD 操作。 +""" + +# backend/app/crud/crud_announcement.py + +from typing import Optional, List, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, delete, func +from sqlalchemy.exc import SQLAlchemyError + +from models.system_announcement import SystemAnnouncement as Announcement +from core.exceptions import DatabaseOperationFailedException +from service.announcement_cache_service import AnnouncementCacheService + + +class CRUDAnnouncement: + """ + 公告数据操作类。 + + 提供系统公告的增删改查功能。 + """ + + @staticmethod + async def get_list( + db: AsyncSession, + status: str | None, + page: int, + page_size: int + ): + """ + 获取公告列表(用户端使用)。 + + Args: + db (AsyncSession): 数据库会话。 + status (str | None): 公告状态筛选。 + page (int): 页码。 + page_size (int): 每页数量。 + + Returns: + Tuple[int, List[Announcement]]: (总记录数, 公告列表)。 + """ + # 只对已发布的公告启用缓存 + if status == "published": + # 尝试从缓存获取 + cached_result = await AnnouncementCacheService.get_list_from_cache(page, page_size) + if cached_result: + total, items = cached_result + # 将字典转换回对象 + announcements = [] + for item in items: + announcement = Announcement() + announcement.announcement_id = item["announcement_id"] + announcement.title = item["title"] + announcement.content = item["content"] + announcement.status = item["status"] + announcement.created_by = item["created_by"] + announcement.created_at = item["created_at"] + announcement.updated_at = item["updated_at"] + announcements.append(announcement) + return total, announcements + + # 缓存未命中,从数据库查询 + query = select(Announcement) + + if status: + query = query.where(Announcement.status == status) + + # total count + count_query = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_query)).scalar() + + # pagination + query = ( + query.order_by(Announcement.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + + rows = (await db.execute(query)).scalars().all() + + # 如果是已发布的公告,缓存结果 + if status == "published" and rows: + # 转换为字典格式用于缓存 + items = [] + for row in rows: + item = { + "announcement_id": row.announcement_id, + "title": row.title, + "content": row.content, + "status": row.status, + "created_by": row.created_by, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None + } + items.append(item) + + # 设置缓存 + await AnnouncementCacheService.set_list_to_cache(page, page_size, total, items) + + return total, rows + + async def get(self, db: AsyncSession, announcement_id: int): + """ + 获取公告详情。 + + Args: + db (AsyncSession): 数据库会话。 + announcement_id (int): 公告 ID。 + + Returns: + Optional[Announcement]: 公告对象或 None。 + """ + # 尝试从缓存获取 + cached_data = await AnnouncementCacheService.get_detail_from_cache(announcement_id) + if cached_data: + # 将字典转换回对象 + announcement = Announcement() + announcement.announcement_id = cached_data["announcement_id"] + announcement.title = cached_data["title"] + announcement.content = cached_data["content"] + announcement.status = cached_data["status"] + announcement.created_by = cached_data["created_by"] + announcement.created_at = cached_data["created_at"] + announcement.updated_at = cached_data["updated_at"] + return announcement + + # 缓存未命中,从数据库查询 + query = select(Announcement).where(Announcement.announcement_id == announcement_id) + result = await db.execute(query) + announcement = result.scalar_one_or_none() + + # 如果查询到公告且状态为已发布,缓存结果 + if announcement and announcement.status == "published": + announcement_data = { + "announcement_id": announcement.announcement_id, + "title": announcement.title, + "content": announcement.content, + "status": announcement.status, + "created_by": announcement.created_by, + "created_at": announcement.created_at.isoformat() if announcement.created_at else None, + "updated_at": announcement.updated_at.isoformat() if announcement.updated_at else None + } + await AnnouncementCacheService.set_detail_to_cache(announcement_id, announcement_data) + + return announcement + + @staticmethod + async def create(db: AsyncSession, **kwargs) -> Announcement: + """ + 创建新公告。 + + Args: + db (AsyncSession): 数据库会话。 + **kwargs: 公告字段。 + + Returns: + Announcement: 创建的公告对象。 + + Raises: + DatabaseOperationFailedException: 创建失败时抛出。 + """ + try: + obj = Announcement(**kwargs) + db.add(obj) + await db.commit() + await db.refresh(obj) + + # 如果是已发布的公告,清除列表缓存 + if kwargs.get("status") == "published": + await AnnouncementCacheService.invalidate_list_cache() + + return obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("create announcement") from e + + @staticmethod + async def update(db: AsyncSession, announcement_id: int, update_data: Dict[str, Any]): + """ + 更新公告。 + + Args: + db (AsyncSession): 数据库会话。 + announcement_id (int): 公告 ID。 + update_data (Dict[str, Any]): 更新的数据字典。 + + Returns: + Optional[Announcement]: 更新后的公告对象或 None。 + + Raises: + DatabaseOperationFailedException: 更新失败时抛出。 + """ + try: + query = ( + update(Announcement) + .where(Announcement.announcement_id == announcement_id) + .values(**update_data) + .returning(Announcement) + ) + result = await db.execute(query) + obj = result.scalar_one_or_none() + await db.commit() + + # 如果公告状态或内容发生变化,清除相关缓存 + if obj: + # 清除列表缓存 + await AnnouncementCacheService.invalidate_list_cache() + # 清除详情缓存 + await AnnouncementCacheService.invalidate_detail_cache(announcement_id) + + return obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("update announcement") from e + + @staticmethod + async def remove(db: AsyncSession, announcement_id: int) -> bool: + """ + 删除公告。 + + Args: + db (AsyncSession): 数据库会话。 + announcement_id (int): 公告 ID。 + + Returns: + bool: 删除成功返回 True,否则返回 False。 + + Raises: + DatabaseOperationFailedException: 删除失败时抛出。 + """ + try: + query = delete(Announcement).where( + Announcement.announcement_id == announcement_id + ) + result = await db.execute(query) + await db.commit() + + # 清除相关缓存 + if result.rowcount > 0: + # 清除列表缓存 + await AnnouncementCacheService.invalidate_list_cache() + # 清除详情缓存 + await AnnouncementCacheService.invalidate_detail_cache(announcement_id) + + return result.rowcount > 0 + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("delete announcement") from e + + +# 正确实例名称 +crud_announcement = CRUDAnnouncement() diff --git a/src/backend/app/crud/crud_database_instance.py b/src/backend/app/crud/crud_database_instance.py new file mode 100644 index 0000000..14eab13 --- /dev/null +++ b/src/backend/app/crud/crud_database_instance.py @@ -0,0 +1,197 @@ +""" +数据库实例 CRUD。 + +本模块提供数据库连接实例管理的 CRUD 操作。 +""" + +# backend/app/crud/crud_database_instance.py + +from typing import Optional, List +from sqlalchemy.future import select +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError + +from models.database_instance import DatabaseInstance +from core.exceptions import DatabaseOperationFailedException + + +class CRUDDatabaseInstance: + """ + 数据库实例数据操作类。 + + 提供数据库实例的增删改查功能。 + """ + + @staticmethod + async def create(db: AsyncSession, **kwargs) -> DatabaseInstance: + """ + 创建新的数据库实例。 + + Args: + db (AsyncSession): 数据库会话。 + **kwargs: 数据库实例字段。 + + Returns: + DatabaseInstance: 创建的数据库实例对象。 + + Raises: + DatabaseOperationFailedException: 如果发生数据库错误。 + """ + try: + db_obj = DatabaseInstance(**kwargs) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("create database instance") from e + + @staticmethod + async def get(db: AsyncSession, instance_id: int) -> Optional[DatabaseInstance]: + """ + 根据实例ID获取数据库实例。 + + Args: + db (AsyncSession): 数据库会话。 + instance_id (int): 实例ID。 + + Returns: + Optional[DatabaseInstance]: 如果找到返回数据库实例对象,否则返回 None。 + + Raises: + DatabaseOperationFailedException: 如果发生数据库错误。 + """ + try: + query = select(DatabaseInstance).where(DatabaseInstance.instance_id == instance_id) + result = await db.execute(query) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get database instance") from e + + @staticmethod + async def get_by_status(db: AsyncSession, status: str, skip: int = 0, limit: int = 100) -> List[DatabaseInstance]: + """ + 根据状态获取数据库实例列表。 + + Args: + db (AsyncSession): 数据库会话。 + status (str): 实例状态。 + skip (int): 跳过的记录数。 + limit (int): 最大返回记录数。 + + Returns: + List[DatabaseInstance]: 数据库实例对象列表。 + + Raises: + DatabaseOperationFailedException: 如果发生数据库错误。 + """ + try: + query = select(DatabaseInstance).where(DatabaseInstance.status == status).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get database instances by status") from e + + @staticmethod + async def get_multi(db: AsyncSession, skip: int = 0, limit: int = 100) -> List[DatabaseInstance]: + """ + 分页获取多个数据库实例。 + + Args: + db (AsyncSession): 数据库会话。 + skip (int): 跳过的记录数。 + limit (int): 最大返回记录数。 + + Returns: + List[DatabaseInstance]: 数据库实例对象列表。 + + Raises: + DatabaseOperationFailedException: 如果发生数据库错误。 + """ + try: + query = select(DatabaseInstance).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get multiple database instances") from e + + @staticmethod + async def update(db: AsyncSession, db_obj: DatabaseInstance, **kwargs) -> DatabaseInstance: + """ + 更新数据库实例。 + + Args: + db (AsyncSession): 数据库会话。 + db_obj (DatabaseInstance): 要更新的数据库实例对象。 + **kwargs: 要更新的字段。 + + Returns: + DatabaseInstance: 更新后的数据库实例对象。 + + Raises: + DatabaseOperationFailedException: 如果发生数据库错误。 + """ + try: + for field, value in kwargs.items(): + setattr(db_obj, field, value) + await db.commit() + await db.refresh(db_obj) + return db_obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("update database instance") from e + + @staticmethod + async def remove(db: AsyncSession, instance_id: int) -> bool: + """ + 根据实例ID删除数据库实例。 + + Args: + db (AsyncSession): 数据库会话。 + instance_id (int): 实例ID。 + + Returns: + bool: 如果实例被删除返回 True,如果未找到实例返回 False。 + + Raises: + DatabaseOperationFailedException: 如果发生数据库错误。 + """ + try: + query = delete(DatabaseInstance).where(DatabaseInstance.instance_id == instance_id) + result = await db.execute(query) + await db.commit() + return result.rowcount > 0 + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("delete database instance") from e + + @staticmethod + async def change_status(db: AsyncSession, instance_id: int, status: str) -> Optional[DatabaseInstance]: + """ + 更改数据库实例状态。 + + Args: + db (AsyncSession): 数据库会话。 + instance_id (int): 实例ID。 + status (str): 新的状态值。 + + Returns: + Optional[DatabaseInstance]: 更新后的数据库实例对象,如果未找到实例返回 None。 + + Raises: + DatabaseOperationFailedException: 如果发生数据库错误。 + """ + try: + instance = await CRUDDatabaseInstance.get(db, instance_id) + if instance: + instance.status = status + await db.commit() + await db.refresh(instance) + return instance + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("change database instance status") from e + +crud_database_instance = CRUDDatabaseInstance() diff --git a/src/backend/app/crud/crud_knowledge.py b/src/backend/app/crud/crud_knowledge.py new file mode 100644 index 0000000..f6e8754 --- /dev/null +++ b/src/backend/app/crud/crud_knowledge.py @@ -0,0 +1,280 @@ +""" +知识库 CRUD。 + +本模块提供领域知识(术语)管理的 CRUD 操作。 +""" + +# backend/app/crud/crud_knowledge.py + +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import func, delete +from sqlalchemy.exc import SQLAlchemyError + +from models.domain_knowledge import DomainKnowledge +from core.exceptions import DatabaseOperationFailedException + + +class CRUDKnowledge: + """ + 知识库(术语)数据操作类。 + + 提供领域知识/术语的增删改查及批量操作功能。 + """ + + @staticmethod + async def create(db: AsyncSession, project_id: int, **kwargs) -> DomainKnowledge: + """ + 创建单个术语。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目 ID。 + **kwargs: 术语字段。 + + Returns: + DomainKnowledge: 创建的术语对象。 + + Raises: + DatabaseOperationFailedException: 创建失败时抛出。 + """ + try: + db_obj = DomainKnowledge(project_id=project_id, **kwargs) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("create knowledge") from e + + @staticmethod + async def get_by_project( + db: AsyncSession, + project_id: int, + skip: int = 0, + limit: int = 20, + search: Optional[str] = None + ) -> List[DomainKnowledge]: + """ + 获取项目下的术语列表 (支持搜索和分页)。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目 ID。 + skip (int): 跳过的记录数。 + limit (int): 返回的最大记录数。 + search (Optional[str]): 搜索关键字(术语名称)。 + + Returns: + List[DomainKnowledge]: 术语列表。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = select(DomainKnowledge).where(DomainKnowledge.project_id == project_id) + + if search: + query = query.where(DomainKnowledge.term.ilike(f"%{search}%")) + + query = query.order_by(DomainKnowledge.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get knowledge list") from e + + @staticmethod + async def get_total_count(db: AsyncSession, project_id: int, search: Optional[str] = None) -> int: + """ + 获取总数。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目 ID。 + search (Optional[str]): 搜索关键字。 + + Returns: + int: 记录总数。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = select(func.count(DomainKnowledge.knowledge_id)).where(DomainKnowledge.project_id == project_id) + if search: + query = query.where(DomainKnowledge.term.ilike(f"%{search}%")) + result = await db.execute(query) + return result.scalar_one() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get knowledge count") from e + + @staticmethod + async def get_all_by_project(db: AsyncSession, project_id: int) -> List[DomainKnowledge]: + """ + 获取项目下所有术语 (用于导出)。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目 ID。 + + Returns: + List[DomainKnowledge]: 所有术语列表。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = select(DomainKnowledge).where(DomainKnowledge.project_id == project_id) + result = await db.execute(query) + return result.scalars().all() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get all knowledge") from e + + # 更新单条术语 + @staticmethod + async def update(db: AsyncSession, db_obj: DomainKnowledge, update_data: dict) -> DomainKnowledge: + """ + 更新单条术语。 + + Args: + db (AsyncSession): 数据库会话。 + db_obj (DomainKnowledge): 要更新的术语对象。 + update_data (dict): 更新的数据字典。 + + Returns: + DomainKnowledge: 更新后的术语对象。 + + Raises: + DatabaseOperationFailedException: 更新失败时抛出。 + """ + try: + for field,value in update_data.items(): + setattr(db_obj, field, value) + await db.commit() + await db.refresh(db_obj) + await db.refresh(db_obj) + return db_obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("update knowledge") from e + + # 删除单条术语 + @staticmethod + async def delete(db: AsyncSession, knowledge_id: int) -> bool: + """ + 删除单条术语。 + + Args: + db (AsyncSession): 数据库会话。 + knowledge_id (int): 术语 ID。 + + Returns: + bool: 删除成功返回 True。 + + Raises: + DatabaseOperationFailedException: 删除失败时抛出。 + """ + try: + query = delete(DomainKnowledge).where(DomainKnowledge.knowledge_id == knowledge_id) + result = await db.execute(query) + await db.commit() + return result.rowcount > 0 + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("delete knowledge") from e + + # 批量删除术语 + @staticmethod + async def remove_multi(db: AsyncSession, project_id: int, ids: List[int]) -> int: + """ + 批量删除术语。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目 ID。 + ids (List[int]): 要删除的术语 ID 列表。 + + Returns: + int: 成功删除的记录数。 + + Raises: + DatabaseOperationFailedException: 删除失败时抛出。 + """ + try : + query = delete(DomainKnowledge).where( + DomainKnowledge.knowledge_id.in_(ids), + DomainKnowledge.project_id == project_id + ) + result = await db.execute(query) + await db.commit() + return result.rowcount + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("batch delete knowledge") from e + + # 通过ID查找单条术语 + @staticmethod + async def get(db: AsyncSession, knowledge_id: int) -> Optional[DomainKnowledge]: + """ + 通过 ID 查找单条术语。 + + Args: + db (AsyncSession): 数据库会话。 + knowledge_id (int): 术语 ID。 + + Returns: + Optional[DomainKnowledge]: 术语对象或 None。 + """ + result = await db.execute(select(DomainKnowledge).where(DomainKnowledge.knowledge_id == knowledge_id)) + return result.scalar_one() + + @staticmethod + async def batch_create(db: AsyncSession, project_id: int, items: List[dict]) -> int: + """ + 批量创建术语 (用于导入)。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目 ID。 + items (List[dict]): 术语数据列表。 + + Returns: + int: 创建的记录数。 + + Raises: + DatabaseOperationFailedException: 创建失败时抛出。 + """ + try: + # 转换为 ORM 对象列表 + db_objs = [DomainKnowledge(project_id=project_id, **item) for item in items] + db.add_all(db_objs) + await db.commit() + return len(db_objs) + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("batch create knowledge") from e + + @staticmethod + async def check_term_exists(db: AsyncSession, project_id: int, term: str) -> bool: + """ + 检查术语是否存在。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目 ID。 + term (str): 术语名称。 + + Returns: + bool: 存在返回 True,否则返回 False。 + """ + query = select(DomainKnowledge).where( + DomainKnowledge.project_id == project_id, + DomainKnowledge.term == term + ) + result = await db.execute(query) + return result.scalar_one_or_none() is not None + + +crud_knowledge = CRUDKnowledge() diff --git a/src/backend/app/crud/crud_message.py b/src/backend/app/crud/crud_message.py new file mode 100644 index 0000000..dee0262 --- /dev/null +++ b/src/backend/app/crud/crud_message.py @@ -0,0 +1,106 @@ +""" +消息 CRUD。 + +本模块提供聊天消息和历史记录管理的 CRUD 操作。 +""" + +# backend/app/crud/crud_message.py + +from sqlalchemy.future import select +from sqlalchemy import desc +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List + +# 导入两个模型 +from models.message import Message +from models.ai_generated_statement import AIGeneratedStatement + +class CRUDMessage: + """ + 消息数据操作类。 + + 提供消息的创建和历史记录查询功能。 + """ + + async def create_message(self, db: AsyncSession, session_id: int, content: str, role: str) -> Message: + """ + 创建一条新消息。 + + Args: + db (AsyncSession): 数据库会话。 + session_id (int): 会话 ID。 + content (str): 消息内容。 + role (str): 消息角色 (user/assistant)。 + + Returns: + Message: 创建的消息对象。 + """ + db_obj = Message( + session_id=session_id, + content=content, + message_type=role + ) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + + # backend/app/crud/crud_message.py + +# backend/app/crud/crud_message.py + + async def get_recent_messages(self, db: AsyncSession, session_id: int, limit: int = 50) -> List[Message]: + """ + 获取最近的历史记录,并直接映射 SQL 执行结果。 + """ + # 1. 查消息基础信息 + query = select(Message)\ + .filter(Message.session_id == session_id)\ + .order_by(desc(Message.created_at))\ + .limit(limit) + + result = await db.execute(query) + messages = result.scalars().all() + + if not messages: + return [] + + # 2. 收集消息 ID + message_ids = [getattr(m, "message_id", getattr(m, "id", None)) for m in messages] + + # 3. 查关联的 SQL 详情和结果 (来自 ai_generated_statement 表) + stmt_map = {} + if message_ids: + # 获取 SQL 文本、类型和执行结果 [cite: 638, 811-820] + stmt_query = select(AIGeneratedStatement).where( + AIGeneratedStatement.message_id.in_(message_ids) + ) + stmt_result = await db.execute(stmt_query) + ai_statements = stmt_result.scalars().all() + + # 建立 ID 映射 + for stmt in ai_statements: + stmt_map[stmt.message_id] = stmt + + # 4. 【核心修复】:直接映射到 ChatResponse 需要的字段 + for m in messages: + mid = getattr(m, "message_id", getattr(m, "id", None)) + stmt = stmt_map.get(mid) + + if stmt: + # 直接给对象赋值,名称必须与 ChatResponse 中的定义一致 + m.sql_text = stmt.sql_text + m.sql_type = stmt.statement_type + # 这里的 .data 对应 ai_generated_statement 表的 execution_result 字段 [cite: 818, 830] + m.data = stmt.execution_result + else: + # 对于 user 消息或没有 SQL 的消息,设为空 + m.sql_text = None + m.sql_type = "UNKNOWN" + m.data = None + + # 5. 返回正序列表,满足历史加载需求 + return list(reversed(messages)) + +# 实例化对象 +crud_message = CRUDMessage() \ No newline at end of file diff --git a/src/backend/app/crud/crud_project.py b/src/backend/app/crud/crud_project.py new file mode 100644 index 0000000..30249df --- /dev/null +++ b/src/backend/app/crud/crud_project.py @@ -0,0 +1,177 @@ +""" +项目 CRUD。 + +本模块提供项目管理的 CRUD 操作。 +""" + +# backend/app/crud/crud_project.py + +from typing import Optional, List +from sqlalchemy.future import select +from sqlalchemy import update, delete, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError +from datetime import datetime, timezone + +# 隐式绝对导入 +from models.project import Project +from models.database_instance import DatabaseInstance # <--- 1. 新增导入 +from core.exceptions import DatabaseOperationFailedException + + +class CRUDProject: + @staticmethod + async def create(db: AsyncSession, **kwargs) -> Project: + """创建新项目""" + try: + # 自动填充时间 + if 'created_at' not in kwargs: + kwargs['created_at'] = datetime.now(timezone.utc) + if 'updated_at' not in kwargs: + kwargs['updated_at'] = datetime.now(timezone.utc) + + db_obj = Project(**kwargs) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + except SQLAlchemyError as e: + await db.rollback() + # 记录详细的错误信息 + from core.log import log + log.error(f"创建项目失败,详细错误: {type(e).__name__}: {str(e)}") + log.error(f"项目数据: {kwargs}") + raise DatabaseOperationFailedException("create project") from e + + @staticmethod + async def get(db: AsyncSession, project_id: int) -> Optional[Project]: + """获取单个项目""" + try: + #同时查 Project 和 DatabaseInstance.db_type + query = select(Project, DatabaseInstance.db_type) \ + .join(DatabaseInstance, Project.instance_id == DatabaseInstance.instance_id) \ + .where(Project.project_id == project_id) + + result = await db.execute(query) + row = result.first() # 获取第一行结果,形式为 (Project实例, db_type字符串) + + if row: + project_obj, db_type_val = row + # 动态将 db_type 属性挂载到 project_obj 上 + # 这样 Pydantic (from_attributes=True) 就能读取到 project_obj.db_type + setattr(project_obj, "db_type", db_type_val) + return project_obj + + return None + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get project") from e + + @staticmethod + async def get_by_user( + db: AsyncSession, user_id: int, skip: int = 0, limit: int = 100, search: Optional[str] = None + ) -> List[Project]: + """ + 获取指定用户的项目列表,支持分页和模糊搜索。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户ID。 + skip (int, optional): 跳过的记录数。 + limit (int, optional): 返回的最大记录数。 + search (Optional[str], optional): 项目名称模糊搜索关键字。 + + Returns: + List[Project]: 项目对象列表。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + # Join DatabaseInstance + query = select(Project, DatabaseInstance.db_type).join( + DatabaseInstance, Project.instance_id == DatabaseInstance.instance_id + ).where( + Project.user_id == user_id, + Project.project_status != 'deleted' + ) + + if search: + query = query.where(Project.project_name.ilike(f'%{search}%')) + + query = query.offset(skip).limit(limit).order_by(Project.updated_at.desc()) + + result = await db.execute(query) + rows = result.all() # 结果是 list of (Project, str) + + projects = [] + for p, dt in rows: + # 动态赋值 + setattr(p, "db_type", dt) + projects.append(p) + + return projects + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get projects list") from e + + @staticmethod + async def get_total_count_by_user( + db: AsyncSession, user_id: int, search: Optional[str] = None + ) -> int: + """ + 获取指定用户的项目总数(用于分页)。 + + Args: + db (AsyncSession): SQLAlchemy异步数据库会话。 + user_id (int): 用户的唯一标识ID。 + search (Optional[str], optional): 项目名称模糊搜索关键字。 + + Returns: + int: 满足条件的项目总数。 + + Raises: + DatabaseOperationFailedException: 数据库操作失败时抛出。 + SQLAlchemyError: SQLAlchemy底层异常。 + """ + try: + query = select(func.count(Project.project_id)).where( + Project.user_id == user_id, + Project.project_status != 'deleted' + ) + if search: + query = query.where(Project.project_name.ilike(f'%{search}%')) + result = await db.execute(query) + return result.scalar_one() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get project count") from e + + @staticmethod + async def update(db: AsyncSession, project_id: int, **kwargs) -> Optional[Project]: + """更新项目""" + try: + kwargs['updated_at'] = datetime.now(timezone.utc) + query = update(Project).where(Project.project_id == project_id).values(**kwargs).execution_options( + synchronize_session="fetch") + await db.execute(query) + await db.commit() + return await CRUDProject.get(db, project_id) + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("update project") from e + + @staticmethod + async def change_status(db: AsyncSession, project_id: int, status: str) -> Optional[Project]: + """ + 更改项目状态(用于软删除)。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目ID。 + status (str): 新的项目状态。 + + Returns: + Optional[Project]: 更新后的项目对象或 None。 + """ + return await CRUDProject.update(db, project_id, project_status=status) + + +crud_project = CRUDProject() \ No newline at end of file diff --git a/src/backend/app/crud/crud_report.py b/src/backend/app/crud/crud_report.py new file mode 100644 index 0000000..2220d93 --- /dev/null +++ b/src/backend/app/crud/crud_report.py @@ -0,0 +1,149 @@ +""" +报表 CRUD。 + +本模块提供分析报表和查询结果的 CRUD 操作。 +""" + +# backend/app/crud/crud_report.py + +from typing import List, Optional, Tuple +from sqlalchemy.ext.asyncio import AsyncSession as Session +from sqlalchemy.future import select +from sqlalchemy import desc +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy import update +# 导入所有相关模型以建立连接路径 +from models.query_result import QueryResult +from models.ai_generated_statement import AIGeneratedStatement +from models.message import Message +from models.session import Session as SessionModel # 别名避免与 DB Session 混淆 + +from core.exceptions import DatabaseOperationFailedException + + +class CRUDReport: + """ + 报表数据操作类。 + + 负责从 query_result, ai_generated_statement 等表中提取数据。 + """ + + @staticmethod + async def get_report_data_by_result_id(db: Session, result_id: int) -> Optional[ + Tuple[QueryResult, AIGeneratedStatement]]: + """ + 根据 result_id 查找查询结果及其关联的 SQL 语句。 + + Args: + db (Session): 数据库会话。 + result_id (int): 结果 ID。 + + Returns: + Optional[Tuple[QueryResult, AIGeneratedStatement]]: 查询结果与语句的元组,若未找到则返回 None。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + # 这是一个复杂的 JOIN 查询 + query = select(QueryResult, AIGeneratedStatement).join( + AIGeneratedStatement,QueryResult.statement_id == AIGeneratedStatement.statement_id + ).where(QueryResult.result_id == result_id) + + result = await db.execute(query) + return result.one_or_none() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get report data by result_id") from e + + @staticmethod + async def get_history_by_project(db: Session, project_id: int) -> List[QueryResult]: + """ + 根据项目ID获取历史查询结果列表(用于列表接口)。 + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + + Returns: + List[QueryResult]: 查询结果列表。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + # 实际查询需要 JOIN project, session, message, statement 来过滤 project_id + # 这里简化为只查询 QueryResult + query = select(QueryResult).order_by(QueryResult.cached_at.desc()) + result = await db.execute(query) + return result.scalars().all() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get history queries by project") from e + + @staticmethod + async def get_by_project(db: Session, project_id: int) -> List[QueryResult]: + """ + 获取项目报表列表。 + + 路径: QueryResult -> Statement -> Message -> Session -> Project + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + + Returns: + List[QueryResult]: 查询结果列表。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = ( + select(QueryResult) + .join(AIGeneratedStatement, QueryResult.statement_id == AIGeneratedStatement.statement_id) + .join(Message, AIGeneratedStatement.message_id == Message.message_id) + .join(SessionModel, Message.session_id == SessionModel.session_id) + .where(SessionModel.project_id == project_id) # 核心筛选条件 + .order_by(desc(QueryResult.cached_at)) + ) + + result = await db.execute(query) + return result.scalars().all() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get reports by project") from e + + # 兼容旧代码调用 + get_history_by_project = get_by_project + + @staticmethod + async def update_chart_type(db: Session, result_id: int, chart_type: str): + """ + 更新报表的图表类型。 + + Args: + db (Session): 数据库会话。 + result_id (int): 结果 ID。 + chart_type (str): 新的图表类型。 + + Returns: + Optional[QueryResult]: 更新后的查询结果对象。 + + Raises: + DatabaseOperationFailedException: 更新失败时抛出。 + """ + try: + query = ( + update(QueryResult) + .where(QueryResult.result_id == result_id) + .values(chart_type=chart_type) + .returning(QueryResult) + ) + + result = await db.execute(query) + obj = result.scalar_one_or_none() + await db.commit() + return obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("update chart type failed") from e + +crud_report = CRUDReport() \ No newline at end of file diff --git a/src/backend/app/crud/crud_session.py b/src/backend/app/crud/crud_session.py new file mode 100644 index 0000000..07aadee --- /dev/null +++ b/src/backend/app/crud/crud_session.py @@ -0,0 +1,281 @@ +""" +会话 CRUD。 + +本模块提供聊天会话的 CRUD 操作。 +""" + +# backend/app/crud/crud_session.py + +from typing import Optional, List +from sqlalchemy.future import select +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError + +from models.session import Session +from core.exceptions import DatabaseOperationFailedException +from core.log import log + + +class CRUDSession: + @staticmethod + async def create(db: AsyncSession, **kwargs) -> Session: + """ + 创建新的会话。 + + Args: + db (AsyncSession): 数据库会话。 + **kwargs: 会话字段。 + + Returns: + Session: 创建的会话对象。 + + Raises: + DatabaseOperationFailedException: 创建失败时抛出。 + """ + try: + log.info("create session") + db_obj = Session(**kwargs) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("create session") from e + + @staticmethod + async def get(db: AsyncSession, session_id: int) -> Optional[Session]: + """ + 根据会话ID获取会话。 + + Args: + db (AsyncSession): 数据库会话。 + session_id (int): 会话ID。 + + Returns: + Optional[Session]: 如果找到返回会话对象,否则返回None。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = select(Session).where(Session.session_id == session_id) + result = await db.execute(query) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get session") from e + + @staticmethod + async def get_by_project(db: AsyncSession, project_id: int, skip: int = 0, limit: int = 100) -> List[Session]: + """ + 根据项目ID获取会话列表(按最后活动时间降序,最近活跃的会话在前)。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目ID。 + skip (int): 跳过的记录数。 + limit (int): 最大返回记录数。 + + Returns: + List[Session]: 会话对象列表。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = ( + select(Session) + .where(Session.project_id == project_id) + .order_by(Session.last_activity.desc()) # 按最后活动时间排序,最近活跃在前 + .offset(skip) + .limit(limit) + ) + result = await db.execute(query) + return result.scalars().all() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get sessions by project") from e + + @staticmethod + async def get_by_user(db: AsyncSession, user_id: int, skip: int = 0, limit: int = 100) -> List[Session]: + """ + 根据用户ID获取会话列表(通过项目关联,按创建时间降序)。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户ID。 + skip (int): 跳过的记录数。 + limit (int): 最大返回记录数。 + + Returns: + List[Session]: 会话对象列表。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + from models.project import Project + query = ( + select(Session) + .join(Project, Session.project_id == Project.project_id) + .where(Project.user_id == user_id) + .order_by(Session.created_at.desc()) # 最新会话在前 + .offset(skip) + .limit(limit) + ) + result = await db.execute(query) + return result.scalars().all() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get sessions by user") from e + + @staticmethod + async def count_by_project(db: AsyncSession, project_id: int) -> int: + """ + 根据项目ID获取会话总数。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目ID。 + + Returns: + int: 会话总数。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + from sqlalchemy import func + query = select(func.count(Session.session_id)).where(Session.project_id == project_id) + result = await db.execute(query) + return result.scalar() or 0 + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("count sessions by project") from e + + @staticmethod + async def count_by_user(db: AsyncSession, user_id: int) -> int: + """ + 根据用户ID获取会话总数(通过项目关联)。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户ID。 + + Returns: + int: 会话总数。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + from sqlalchemy import func + from models.project import Project + query = ( + select(func.count(Session.session_id)) + .join(Project, Session.project_id == Project.project_id) + .where(Project.user_id == user_id) + ) + result = await db.execute(query) + return result.scalar() or 0 + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("count sessions by user") from e + + @staticmethod + async def get_multi(db: AsyncSession, skip: int = 0, limit: int = 100) -> List[Session]: + """ + 分页获取多个会话。 + + Args: + db (AsyncSession): 数据库会话。 + skip (int): 跳过的记录数。 + limit (int): 最大返回记录数。 + + Returns: + List[Session]: 会话对象列表。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = select(Session).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get multiple sessions") from e + + @staticmethod + async def update(db: AsyncSession, db_obj: Session, **kwargs) -> Session: + """ + 更新会话。 + + Args: + db (AsyncSession): 数据库会话。 + db_obj (Session): 要更新的会话对象。 + **kwargs: 要更新的字段。 + + Returns: + Session: 更新后的会话对象。 + + Raises: + DatabaseOperationFailedException: 更新失败时抛出。 + """ + try: + for field, value in kwargs.items(): + setattr(db_obj, field, value) + await db.commit() + await db.refresh(db_obj) + return db_obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("update session") from e + + @staticmethod + async def remove(db: AsyncSession, session_id: int) -> bool: + """ + 根据会话ID删除会话。 + + Args: + db (AsyncSession): 数据库会话。 + session_id (int): 会话ID。 + + Returns: + bool: 如果会话被删除返回True,如果未找到会话返回False。 + + Raises: + DatabaseOperationFailedException: 删除失败时抛出。 + """ + try: + query = delete(Session).where(Session.session_id == session_id) + result = await db.execute(query) + await db.commit() + return result.rowcount > 0 + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("delete session") from e + + @staticmethod + async def update_last_activity(db: AsyncSession, session_id: int) -> Optional[Session]: + """ + 更新会话的最后活动时间。 + + Args: + db (AsyncSession): 数据库会话。 + session_id (int): 会话ID。 + + Returns: + Optional[Session]: 更新后的会话对象,如果未找到会话返回None。 + + Raises: + DatabaseOperationFailedException: 更新失败时抛出。 + """ + try: + session = await CRUDSession.get(db, session_id) + if session: + + await db.commit() + await db.refresh(session) + return session + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("update session last activity") from e + +crud_session = CRUDSession() \ No newline at end of file diff --git a/src/backend/app/crud/crud_user_account.py b/src/backend/app/crud/crud_user_account.py new file mode 100644 index 0000000..1a5a49b --- /dev/null +++ b/src/backend/app/crud/crud_user_account.py @@ -0,0 +1,212 @@ +""" +用户账户 CRUD。 + +本模块提供用户账户管理的 CRUD 操作。 +""" + +# backend/app/crud/crud_user_account.py + +from typing import Optional, List +from sqlalchemy.future import select +from sqlalchemy import update, delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.sql.operators import or_ + +from models.user_account import UserAccount +from core.exceptions import DatabaseOperationFailedException + + +class CRUDUserAccount: + @staticmethod + async def create(db: AsyncSession, **kwargs) -> UserAccount: + """ + 创建新的用户账户。 + + Args: + db (AsyncSession): 数据库会话。 + **kwargs: 用户账户字段。 + + Returns: + UserAccount: 创建的用户账户对象。 + + Raises: + DatabaseOperationFailedException: 创建失败时抛出。 + """ + try: + db_obj = UserAccount(**kwargs) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("create user") from e + + @staticmethod + async def get(db: AsyncSession, user_id: int) -> Optional[UserAccount]: + """ + 根据用户ID获取用户账户。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户ID。 + + Returns: + Optional[UserAccount]: 如果找到返回用户账户对象,否则返回None。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = select(UserAccount).where(UserAccount.user_id == user_id) + result = await db.execute(query) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get user") from e + + @staticmethod + async def get_by_username(db: AsyncSession, username: str) -> Optional[UserAccount]: + """ + 根据用户名获取用户账户。 + + Args: + db (AsyncSession): 数据库会话。 + username (str): 用户名。 + + Returns: + Optional[UserAccount]: 如果找到返回用户账户对象,否则返回None。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = select(UserAccount).where(UserAccount.username == username) + result = await db.execute(query) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get user by username") from e + + @staticmethod + async def get_by_email(db: AsyncSession, email: str) -> Optional[UserAccount]: + """ + 根据邮箱获取用户账户。 + + Args: + db (AsyncSession): 数据库会话。 + email (str): 邮箱地址。 + + Returns: + Optional[UserAccount]: 如果找到返回用户账户对象,否则返回None。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = select(UserAccount).where(UserAccount.email == email) + result = await db.execute(query) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get user by email") from e + + @staticmethod + async def get_multi(db: AsyncSession, skip: int = 0, limit: int = 100) -> List[UserAccount]: + """ + 分页获取多个用户账户。 + + Args: + db (AsyncSession): 数据库会话。 + skip (int): 跳过的记录数。 + limit (int): 最大返回记录数。 + + Returns: + List[UserAccount]: 用户账户对象列表。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = select(UserAccount).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get multiple users") from e + + @staticmethod + async def update(db: AsyncSession, db_obj: UserAccount, **kwargs) -> UserAccount: + """ + 更新用户账户。 + + Args: + db (AsyncSession): 数据库会话。 + db_obj (UserAccount): 要更新的用户账户对象。 + **kwargs: 要更新的字段。 + + Returns: + UserAccount: 更新后的用户账户对象。 + + Raises: + DatabaseOperationFailedException: 更新失败时抛出。 + """ + try: + for field, value in kwargs.items(): + setattr(db_obj, field, value) + await db.commit() + await db.refresh(db_obj) + return db_obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("update user") from e + + @staticmethod + async def remove(db: AsyncSession, user_id: int) -> bool: + """ + 根据用户ID删除用户账户。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户ID。 + + Returns: + bool: 如果用户被删除返回True,如果未找到用户返回False。 + + Raises: + DatabaseOperationFailedException: 删除失败时抛出。 + """ + try: + query = delete(UserAccount).where(UserAccount.user_id == user_id) + result = await db.execute(query) + await db.commit() + return result.rowcount > 0 + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("delete user") from e + + @staticmethod + async def get_by_username_or_email(db: AsyncSession, username_or_email: str) -> Optional[UserAccount]: + """ + 根据用户名或邮箱获取用户。 + + Args: + db (AsyncSession): 数据库会话。 + username_or_email (str): 用户名或邮箱。 + + Returns: + Optional[UserAccount]: 如果找到返回用户账户对象,否则返回None。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = select(UserAccount).where( + or_( + UserAccount.username == username_or_email, + UserAccount.email == username_or_email + ) + ) + result = await db.execute(query) + return result.scalar_one_or_none() + except SQLAlchemyError as e: + raise DatabaseOperationFailedException("get user by username or email") from e + +crud_user_account = CRUDUserAccount() diff --git a/src/backend/app/crud/crud_user_login_history.py b/src/backend/app/crud/crud_user_login_history.py new file mode 100644 index 0000000..2203b0f --- /dev/null +++ b/src/backend/app/crud/crud_user_login_history.py @@ -0,0 +1,198 @@ +""" +用户登录历史 CRUD。 + +本模块提供用于追踪用户登录历史的 CRUD 操作。 +""" + +# backend/app/crud/crud_user_login_history.py + +from typing import Optional, List, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, desc +from sqlalchemy.exc import SQLAlchemyError +from datetime import datetime, timezone + +from models.user_login_history import UserLoginHistory +from core.exceptions import DatabaseOperationFailedException +from core.log import log + + +class CRUDLoginHistory: + @staticmethod + async def get_latest_unlogout_record( + db: AsyncSession, user_id: int + ) -> UserLoginHistory | None: + """ + 获取用户最新的「登录成功且未登出」的记录。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户 ID。 + + Returns: + UserLoginHistory | None: 最新未登出的登录记录,若无则返回 None。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + query = ( + select(UserLoginHistory) + .where( + UserLoginHistory.user_id == user_id, + UserLoginHistory.login_status == "success", + UserLoginHistory.logout_time == None + ) + .order_by(UserLoginHistory.login_time.desc()) + .limit(1) + ) + result = await db.execute(query) + + latest_record = result.scalars().first() + log.debug("get latest unlogout record: {}", latest_record) + + return latest_record + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("get latest unlogout record") from e + + @staticmethod + async def create_login_record( + db: AsyncSession, + user_id: Optional[int], + ip_address: str, + login_status: str, + user_agent: str | None = None, + failure_reason: str | None = None, + device_info: dict | None = None, + login_time: datetime | None = None + ) -> UserLoginHistory: + """ + 创建登录记录(登录接口专用)。 + + 对齐表的必填字段(user_id、ip_address、login_status)。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (Optional[int]): 用户ID。 + ip_address (str): IP地址。 + login_status (str): 登录状态。 + user_agent (str | None): 用户代理信息。 + failure_reason (str | None): 失败原因。 + device_info (dict | None): 设备信息。 + login_time (datetime | None): 登录时间。 + + Returns: + UserLoginHistory: 用户登录历史记录对象。 + + Raises: + DatabaseOperationFailedException: 创建失败时抛出。 + """ + try: + db_obj = UserLoginHistory( + user_id=user_id, + login_time=login_time or datetime.now(timezone.utc), + ip_address=ip_address, + user_agent=user_agent, + login_status=login_status, + failure_reason=failure_reason, + device_info=device_info, + created_at=datetime.now(timezone.utc) + ) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("create login record") from e + + @staticmethod + async def update_logout_info( + db: AsyncSession, + login_history: UserLoginHistory, # 要更新的登录记录对象 + logout_time: datetime = datetime.now(timezone.utc) + ) -> None: + """ + 更新登录记录的登出信息(登出时间、会话时长、登录状态)。 + + Args: + db (AsyncSession): 数据库会话。 + login_history (UserLoginHistory): 要更新的登录记录对象。 + logout_time (datetime): 登出时间。 + + Raises: + DatabaseOperationFailedException: 更新失败时抛出。 + """ + try: + # 确保登出时间不早于登录时间 + if logout_time < login_history.login_time: + logout_time = login_history.login_time + log.warning(f"Logout time {logout_time} is earlier than login time {login_history.login_time}, setting logout time to login time") + + # 只做数据赋值,不做业务判断(业务判断在Service层) + login_history.logout_time = logout_time + login_history.session_duration = logout_time - login_history.login_time + login_history.login_status = "forced_logout" + await db.commit() + await db.refresh(login_history) + except SQLAlchemyError as e: + await db.rollback() + raise DatabaseOperationFailedException("update logout info") from e + + # 新增添加分页查询方法 + @staticmethod + async def get_multi_by_user( + db: AsyncSession, + user_id: int, + skip: int = 0, + limit: int = 10 + ) -> Tuple[List[UserLoginHistory], int]: + """ + 获取用户的登录历史列表(支持分页)。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户 ID。 + skip (int): 跳过的记录数。 + limit (int): 返回的最大记录数。 + + Returns: + Tuple[List[UserLoginHistory], int]: (记录列表, 总数)。 + + Raises: + DatabaseOperationFailedException: 查询失败时抛出。 + """ + try: + # 包含所有登录相关的记录:success, failed, expired, forced_logout + # 这些都属于登录历史,应该被展示给用户 + included_statuses = ["success", "failed", "expired", "forced_logout"] + + # 1. 查询总数 + count_query = select(func.count(UserLoginHistory.login_id)).where( + UserLoginHistory.user_id == user_id, + UserLoginHistory.login_status.in_(included_statuses) + ) + total_result = await db.execute(count_query) + total = total_result.scalar_one() + + # 2. 查询列表数据 (按登录时间倒序) + query = ( + select(UserLoginHistory) + .where( + UserLoginHistory.user_id == user_id, + UserLoginHistory.login_status.in_(included_statuses) + ) + .order_by(desc(UserLoginHistory.login_time)) + .offset(skip) + .limit(limit) + ) + result = await db.execute(query) + items = result.scalars().all() + + return items, total + except SQLAlchemyError as e: + # 这里的异常会被 Service 层或 Router 层的通用异常处理捕获 + raise DatabaseOperationFailedException("get login history list") from e + +crud_login_history = CRUDLoginHistory() diff --git a/src/backend/app/main.py b/src/backend/app/main.py new file mode 100644 index 0000000..5736916 --- /dev/null +++ b/src/backend/app/main.py @@ -0,0 +1,16 @@ +# backend/app/main.py + +# 文件路径: app/main.py +import uvicorn +from core.config import config + +# 5. 启动入口 +if __name__ == "__main__": + uvicorn.run( + config.app.uvicorn, + host=config.app.host, + port=config.app.port, + reload=config.app.reload, + proxy_headers=True, # 信任代理头,正确解析 X-Forwarded-For 等 + forwarded_allow_ips="*" # 允许所有来源的转发头(生产环境建议限制为代理服务器 IP) + ) \ No newline at end of file diff --git a/src/backend/app/models/__init__.py b/src/backend/app/models/__init__.py new file mode 100644 index 0000000..75d5271 --- /dev/null +++ b/src/backend/app/models/__init__.py @@ -0,0 +1,36 @@ +""" +数据模型包初始化。 + +导出所有 ORM 模型,并定义了它们的导入顺序以解决依赖关系。 +""" + +# backend/app/models/__init__.py + +# 导入所有模型,确保 SQLAlchemy 能找到它们 +# 注意:导入顺序非常重要,应遵循依赖关系,先导入被依赖的表(基础表),后导入依赖表 + +# 1. 无外键依赖的基础表(必须最先) +from .user_account import UserAccount +from .user_login_history import UserLoginHistory +from .project import Project +from .database_instance import DatabaseInstance +from .ai_model_config import AIModelConfig + +# 2. Session(很多表依赖它,如 Message, OperationLog) +from .session import Session + +# 3. 依赖 Session 的表 +from .operation_log import OperationLog +from .message import Message # Message 依赖 Session + +# 4. 依赖 Message 的表 +from .ai_generated_statement import AIGeneratedStatement +from .query_result import QueryResult + +# 5. 其他无环依赖的表 +from .violation_log import ViolationLog +from .user_ban_log import UserBanLog +from .unban_request import UnbanRequest +from .system_announcement import SystemAnnouncement +from .user_profile_change_log import UserProfileChangeLog +from .domain_knowledge import DomainKnowledge \ No newline at end of file diff --git a/src/backend/app/models/ai_generated_statement.py b/src/backend/app/models/ai_generated_statement.py new file mode 100644 index 0000000..9ca8fac --- /dev/null +++ b/src/backend/app/models/ai_generated_statement.py @@ -0,0 +1,88 @@ +""" +AI 生成语句模型。 + +本模块定义了用于存储 AI 生成的 SQL 语句及其执行状态的 ORM 模型。 +""" + +# backend/app/models/ai_generated_statement.py + +from sqlalchemy import Column, Text, Integer, Index, CheckConstraint, ForeignKey, DateTime +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.sql import func +from core.database import Base + +class AIGeneratedStatement(Base): + """ + AI生成的SQL语句明细表 ORM 模型。 + + 存储 AI 生成的 SQL 语句、执行状态及结果。 + + Attributes: + statement_id (int): 语句ID。 + message_id (int): 所属消息ID。 + statement_order (int): 语句执行顺序。 + sql_text (str): SQL语句文本。 + statement_type (str): 语句类型。 + execution_status (str): 执行状态。 + execution_result (dict): 执行结果。 + executed_at (datetime): 执行时间。 + created_at (datetime): 创建时间。 + """ + __tablename__ = "ai_generated_statement" + + __table_args__ = ( + CheckConstraint("statement_type IN ('SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DDL', 'OTHER')", name='ai_generated_statement_type_check'), + CheckConstraint("execution_status IN ('pending', 'completed', 'failed')", name='ai_generated_statement_status_check'), + Index('idx_ai_statements_message_id', 'message_id'), + {'comment': 'AI生成的SQL语句明细表'} + ) + + statement_id = Column( + Integer, + primary_key=True, + autoincrement=True, + index=True, + comment='语句ID' + ) + message_id = Column( + Integer, + ForeignKey('message.message_id', ondelete='CASCADE'), + nullable=False, + comment='所属消息' + ) + statement_order = Column( + Integer, + nullable=False, + comment='语句执行顺序' + ) + sql_text = Column( + Text, + nullable=False, + comment='SQL语句文本' + ) + statement_type = Column( + Text, + nullable=False, + comment='语句类型' + ) + execution_status = Column( + Text, + default='pending', + nullable=False, + comment='执行状态' + ) + execution_result = Column( + JSONB, + comment='执行结果' + ) + executed_at = Column( + DateTime(timezone=True), + comment='执行时间' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='创建时间' + ) + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/ai_model_config.py b/src/backend/app/models/ai_model_config.py new file mode 100644 index 0000000..fec815c --- /dev/null +++ b/src/backend/app/models/ai_model_config.py @@ -0,0 +1,96 @@ +""" +AI 模型配置模型。 + +本模块定义了用于存储 AI 对话模型配置的 ORM 模型。 +管理员可以动态添加、修改、删除模型配置。 +""" + +# backend/app/models/ai_model_config.py + +from sqlalchemy import Column, Integer, String, DateTime, Index +from sqlalchemy.sql import func +from core.database import Base + + +class AIModelConfig(Base): + """ + AI 模型配置表 ORM 模型(精简版)。 + + 存储 AI 对话模型的核心配置信息。 + + Attributes: + config_id (int): 配置ID,主键。 + model_name (str): 模型显示名称(同时作为唯一标识)。 + api_url (str): API 接口地址。 + model_id (str): 模型在 API 服务中的标识。 + api_key (str): API 密钥。 + model_type (str): 模型类型(local_finetune, general_llm)。 + created_at (datetime): 创建时间。 + updated_at (datetime): 更新时间。 + """ + __tablename__ = 'ai_model_config' + + __table_args__ = ( + Index('idx_ai_model_config_model_name', 'model_name', unique=True), + {'comment': 'AI 对话模型配置表,存储模型连接信息'} + ) + + config_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='配置ID' + ) + model_name = Column( + String(200), + nullable=False, + unique=True, + comment='模型名称(唯一标识)' + ) + api_url = Column( + String(500), + nullable=False, + comment='API 接口地址' + ) + model_id = Column( + String(200), + nullable=False, + comment='模型在 API 服务中的标识' + ) + api_key = Column( + String(500), + nullable=False, + comment='API 密钥' + ) + model_type = Column( + String(50), + nullable=False, + default='general_llm', + comment='模型类型:local_finetune, general_llm' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='创建时间' + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + comment='更新时间' + ) + + def to_registry_format(self) -> dict: + """ + 转换为 MODEL_REGISTRY 格式,用于兼容现有代码。 + + Returns: + dict: 模型配置字典。 + """ + return { + "name": self.model_name, + "api_url": self.api_url, + "model_id": self.model_id, + "api_key": self.api_key, + "type": self.model_type + } diff --git a/src/backend/app/models/database_instance.py b/src/backend/app/models/database_instance.py new file mode 100644 index 0000000..817597f --- /dev/null +++ b/src/backend/app/models/database_instance.py @@ -0,0 +1,138 @@ +""" +数据库实例模型。 + +本模块定义了用于存储物理数据库实例元信息的 ORM 模型。 +""" + +# backend/app/models/database_instance.py + +from sqlalchemy import Column, Integer, String, Text, DateTime, CheckConstraint, Index, URL +from sqlalchemy.sql import func +from core.database import Base +from core.config import config + + +class DatabaseInstance(Base): + """ + 物理数据库实例元信息表 ORM 模型。 + + 存储数据库连接凭证和状态。 + + Attributes: + instance_id (int): 实例ID。 + db_type (str): 数据库类型。 + db_host (str): 数据库主机地址。 + db_port (int): 端口。 + db_name (str): 数据库名。 + db_username (str): 用户名。 + db_password (str): 密码(加密存储)。 + status (str): 实例状态。 + created_at (datetime): 创建时间。 + last_used_at (datetime): 最后使用时间。 + """ + __tablename__ = 'database_instance' + + __table_args__ = ( + CheckConstraint("status IN ('active', 'inactive', 'error')", name='ck_database_status'), + Index('idx_database_instances_status', 'status'), + Index('idx_database_instances_last_used', 'last_used_at'), + {'comment': '物理数据库实例元信息,存储连接凭证和状态'} + ) + + instance_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='实例ID' + ) + db_type = Column( + Text, + nullable=False, + default='mysql', + comment='数据库类型' + ) + db_host = Column( + String(200), + nullable=False, + comment='数据库主机地址' + ) + db_port = Column( + Integer, + default=3306, + comment='端口' + ) + db_name = Column( + String(100), + nullable=False, + comment='数据库名' + ) + db_username = Column( + String(100), + nullable=False, + comment='用户名' + ) + db_password = Column( + String(255), + nullable=False, + comment='密码(加密存储)' + ) + status = Column( + Text, + default='active', + nullable=False, + comment='实例状态' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='创建时间' + ) + last_used_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=True, + comment='最后使用时间' + ) + class Config: + from_attributes = True + + @property + def user_database_url(self) -> URL: + """ + 获取用户数据库连接 URL。 + + Returns: + URL: 用户数据库连接 URL。 + """ + if self.db_type == "mysql": + drivername = config.mysql.driver + return URL.create( + drivername=drivername, + username=self.db_username, + password=self.db_password, + host=self.db_host, + port=self.db_port, + database=self.db_name + ) + elif self.db_type == "postgresql": + drivername = config.postgresql.driver + return URL.create( + drivername=drivername, + username=self.db_username, + password=self.db_password, + host=self.db_host, + port=self.db_port, + database=self.db_name + ) + elif self.db_type == "sqlite": + # SQLite是文件型数据库,只需要文件路径 + db_path = config.sqlite.db_path + import os + db_file_path = os.path.join(db_path, f"{self.db_name}.db") + return URL.create( + drivername=config.sqlite.driver, + database=db_file_path + ) + else: + raise ValueError("无效的数据库类型") \ No newline at end of file diff --git a/src/backend/app/models/domain_knowledge.py b/src/backend/app/models/domain_knowledge.py new file mode 100644 index 0000000..08a8e33 --- /dev/null +++ b/src/backend/app/models/domain_knowledge.py @@ -0,0 +1,68 @@ +""" +领域知识模型。 + +本模块定义了用于存储用户专业知识(术语)的 ORM 模型。 +""" + +# backend/app/models/domain_knowledge.py + +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index +from sqlalchemy.sql import func + +from core.database import Base + +class DomainKnowledge(Base): + """ + 用户专业知识表 ORM 模型。 + + 增强 AI 对业务术语的理解。 + + Attributes: + knowledge_id (int): 知识条目ID。 + project_id (int): 所属项目ID。 + term (str): 业务术语。 + definition (str): 术语定义。 + examples (str): 使用示例。 + created_at (datetime): 创建时间。 + """ + __tablename__ = 'domain_knowledge' + + __table_args__ = ( + Index('idx_domain_knowledge_project_id', 'project_id'), + Index('idx_domain_knowledge_term', 'term'), + {'comment': '用户专业知识表,增强AI对业务术语的理解'} + ) + + knowledge_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='知识条目ID' + ) + project_id = Column( + Integer, + ForeignKey('project.project_id', ondelete='CASCADE'), + nullable=False, + comment='所属项目' + ) + term = Column( + String(100), + nullable=False, + comment='业务术语' + ) + definition = Column( + Text, + nullable=False, + comment='术语定义' + ) + examples = Column( + Text, + comment='使用示例' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='创建时间' + ) + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/message.py b/src/backend/app/models/message.py new file mode 100644 index 0000000..153e1f8 --- /dev/null +++ b/src/backend/app/models/message.py @@ -0,0 +1,76 @@ +""" +消息模型。 + +本模块定义了用于存储用户与 AI 对话内容的 ORM 模型。 +""" + +# backend/app/models/message.py + +from sqlalchemy import Column, Integer, Text, Boolean, CheckConstraint, Index, DateTime, ForeignKey +from sqlalchemy.sql import func + +from core.database import Base + +class Message(Base): + """ + 消息记录表 ORM 模型。 + + 存储用户与 AI 的对话内容。 + + Attributes: + message_id (int): 消息ID。 + session_id (int): 所属会话ID。 + message_type (str): 消息类型:user/assistant/system。 + content (str): 原始文本内容。 + requires_confirmation (bool): 是否需要用户确认。 + user_confirmed (bool): 用户是否确认执行。 + created_at (datetime): 创建时间。 + """ + __tablename__ = 'message' + + __table_args__ = ( + CheckConstraint("message_type IN ('user', 'assistant', 'system')", name='ck_message_type'), + Index('idx_messages_session_id_created', 'session_id', 'created_at'), + Index('idx_messages_message_type', 'message_type'), + {'comment': '消息记录表,存储对话内容'} + ) + + message_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='消息ID' + ) + session_id = Column( + Integer, + ForeignKey('session.session_id', ondelete='CASCADE'), + nullable=False, + comment='所属会话' + ) + message_type = Column( + Text, + nullable=False, + comment='消息类型:user/assistant/system' + ) + content = Column( + Text, + nullable=False, + comment='原始文本内容' + ) + requires_confirmation = Column( + Boolean, + default=False, + comment='是否需要用户确认' + ) + user_confirmed = Column( + Boolean, + default=False, + comment='用户是否确认执行' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='创建时间' + ) + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/operation_log.py b/src/backend/app/models/operation_log.py new file mode 100644 index 0000000..021581b --- /dev/null +++ b/src/backend/app/models/operation_log.py @@ -0,0 +1,135 @@ +""" +操作日志模型。 + +本模块定义了用于记录系统操作审计日志的 ORM 模型。 +""" + +# backend/app/models/operation_log.py + +from sqlalchemy import Column, Integer, Text, String, Boolean, CheckConstraint, DateTime, ForeignKey, Index +from sqlalchemy.sql import func + +from core.database import Base + +class OperationLog(Base): + """ + 操作审计日志表 ORM 模型。 + + 记录所有数据变更操作。 + + Attributes: + log_id (int): 日志ID。 + user_id (int): 操作用户ID。 + project_id (int): 所属项目ID。 + session_id (int): 所属会话ID。 + message_id (int): 触发操作的消息ID。 + statement_id (int): 关联的SQL语句ID。 + operation_type (str): 操作类型。 + target_table (str): 目标表名。 + sql_statement (str): 完整SQL语句。 + natural_language_intent (str): 用户原始指令。 + status (str): 执行状态。 + error_message (str): 错误信息(若失败)。 + affected_rows (int): 影响行数。 + confirmed_by_user (bool): 是否经用户确认。 + executed_at (datetime): 执行时间。 + execution_duration_ms (int): 执行耗时(毫秒)。 + """ + __tablename__ = 'operation_log' + + __table_args__ = ( + CheckConstraint("operation_type IN ('INSERT', 'UPDATE', 'DELETE', 'ALTER', 'DROP', 'CREATE')", name='operation_log_operation_type_check'), + CheckConstraint("status IN ('success', 'failed', 'cancelled')"), + Index('idx_operation_logs_user_project', 'user_id', 'project_id'), + Index('idx_operation_logs_executed_at', 'executed_at'), + Index('idx_operation_logs_status', 'status'), + {'comment': '操作审计日志表,记录所有数据变更操作'} + ) + + log_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='日志ID' + ) + user_id = Column( + Integer, + ForeignKey('user_account.user_id', ondelete='RESTRICT'), + nullable=False, + comment='操作用户' + ) + project_id = Column( + Integer, + ForeignKey('project.project_id', ondelete='CASCADE'), + nullable=False, + comment='所属项目' + ) + session_id = Column( + Integer, + ForeignKey('session.session_id', ondelete='CASCADE'), + nullable=False, + comment='所属会话' + ) + message_id = Column( + Integer, + ForeignKey('message.message_id', ondelete='CASCADE'), + nullable=False, + comment='触发操作的消息' + ) + statement_id = Column( + Integer, + ForeignKey('ai_generated_statement.statement_id', ondelete='CASCADE'), + nullable=False, + comment='关联的SQL语句' + ) + operation_type = Column( + Text, + nullable=False, + comment='操作类型' + ) + target_table = Column( + String(100), + nullable=False, + comment='目标表名' + ) + sql_statement = Column( + Text, + nullable=False, + comment='完整SQL语句' + ) + natural_language_intent = Column( + Text, + nullable=False, + comment='用户原始指令' + ) + status = Column( + Text, + nullable=False, + comment='执行状态' + ) + error_message = Column( + Text, + nullable=True, + comment='错误信息(若失败)' + ) + affected_rows = Column( + Integer, + default=0, + comment='影响行数' + ) + confirmed_by_user = Column( + Boolean, + default=False, + comment='是否经用户确认' + ) + executed_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='执行时间' + ) + execution_duration_ms = Column( + Integer, + comment='执行耗时(毫秒)' + ) + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/project.py b/src/backend/app/models/project.py new file mode 100644 index 0000000..a8501a8 --- /dev/null +++ b/src/backend/app/models/project.py @@ -0,0 +1,124 @@ +""" +项目模型。 + +本模块定义了用于存储用户项目和业务逻辑定义的 ORM 模型。 +""" + +# backend/app/models/project.py + +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, CheckConstraint, Index +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.sql import func +from core.database import Base +from sqlalchemy.orm import relationship + +class Project(Base): + """ + 项目表 ORM 模型,存储用户业务场景的逻辑定义。 + + Attributes: + project_id (int): 项目ID。 + user_id (int): 所属用户ID。 + instance_id (int): 关联的数据库实例ID。 + project_name (str): 项目名称。 + description (str): 业务需求描述。 + schema_definition (dict): AI生成的DDL结构。 + project_status (str): 项目状态。 + created_at (datetime): 创建时间。 + updated_at (datetime): 最后更新时间。 + """ + __tablename__ = 'project' + + # 表注释和约束 + __table_args__ = ( + # 必须包含 'completed',否则部署成功后改状态会报错 + CheckConstraint( + "project_status IN ('active', 'initializing', 'pending_confirmation', 'deleted', 'completed')", + name='ck_project_status' + ), + Index('idx_projects_user_id', 'user_id'), + Index('idx_projects_status', 'project_status'), + Index('idx_projects_updated_at', 'updated_at'), + {'comment': '项目表,存储用户业务场景的逻辑定义'} + ) + + # 列定义 + project_id = Column( + Integer, + autoincrement=True, + primary_key=True, + comment='项目ID' + ) + + # 关联关系 + sessions = relationship("Session", back_populates="project", cascade="all, delete-orphan") + + user_id = Column( + Integer, + ForeignKey('user_account.user_id', ondelete='CASCADE'), + nullable=False, + comment='所属用户' + ) + instance_id = Column( + Integer, + ForeignKey('database_instance.instance_id', ondelete='RESTRICT'), + nullable=False, + comment='关联的数据库实例' + ) + project_name = Column( + String(100), + nullable=False, + comment='项目名称' + ) + description = Column( + Text, + nullable=True, + comment='业务需求描述' + ) + schema_definition = Column( + JSONB, + nullable=True, + comment='AI生成的Schema结构(JSON格式)' + ) + + ddl_statement = Column( + Text, + nullable=True, + comment='AI生成的DDL建表语句' + ) + + # 新字段,用于存 ER 图代码 + er_diagram_code = Column( + Text, + nullable=True, + comment='AI生成的Mermaid ER图代码' + ) + + creation_stage = Column( + String(50), + default='initializing', + nullable=False, + comment='创建进度阶段 (initializing, generating_schema, schema_generated, generating_ddl, ddl_generated, executing_ddl, completed)' + ) + + project_status = Column( + String(50), # 保留你的 String(50),比 Text 更规范 + default='active', + nullable=False, + comment='项目状态' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='创建时间' + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=True, + comment='最后更新时间' + ) + + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/query_result.py b/src/backend/app/models/query_result.py new file mode 100644 index 0000000..25e0161 --- /dev/null +++ b/src/backend/app/models/query_result.py @@ -0,0 +1,67 @@ +""" +查询结果模型。 + +本模块定义了用于缓存 SQL 查询结果的 ORM 模型。 +""" + +# backend/app/models/query_result.py + +from sqlalchemy import Column, Integer, Text, String, DateTime, ForeignKey, Index +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.sql import func + +from core.database import Base + +class QueryResult(Base): + """ + 查询结果缓存表 ORM 模型。 + + 存储 SELECT 查询结果。 + + Attributes: + result_id (int): 结果ID。 + statement_id (int): 关联的SQL语句ID。 + result_data (dict): 查询结果数据(快照)。 + data_summary (str): AI生成的自然语言摘要。 + chart_type (str): 推荐图表类型。 + cached_at (datetime): 缓存时间。 + """ + __tablename__ = 'query_result' + + __table_args__ = ( + Index('idx_query_results_statement_id', 'statement_id'), + Index('idx_query_results_cached_at', 'cached_at'), + {'comment': '查询结果缓存表,存储SELECT查询结果'} + ) + + result_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='结果ID' + ) + statement_id = Column( + Integer, + ForeignKey('ai_generated_statement.statement_id', ondelete='CASCADE'), + nullable=False, + comment='关联的SQL语句ID' + ) + result_data = Column( + JSONB, + comment='查询结果数据(快照)' + ) + data_summary = Column( + Text, + comment='AI生成的自然语言摘要' + ) + chart_type = Column( + String(50), + comment='推荐图表类型(bar, line, pie等)' + ) + cached_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='缓存时间' + ) + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/report.py b/src/backend/app/models/report.py new file mode 100644 index 0000000..b8f1643 --- /dev/null +++ b/src/backend/app/models/report.py @@ -0,0 +1,90 @@ +""" +报表模型。 + +本模块定义了用于存储分析报表配置的 ORM 模型。 +""" + +from sqlalchemy import Column, Integer, String, Text, ForeignKey, JSON, DateTime, Index +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from core.database import Base + +class AnalysisReport(Base): + """ + 报表配置表 ORM 模型。 + + 用于持久化保存用户对某个查询结果的展示配置。 + + Attributes: + report_id (int): 报表ID。 + project_id (int): 所属项目ID。 + result_id (int): 关联的数据源(查询结果)ID。 + name (str): 报表名称。 + description (str): 报表描述。 + chart_type (str): 图表类型 (bar, line, pie, etc.)。 + chart_config (dict): 图表配置。 + created_at (datetime): 创建时间。 + updated_at (datetime): 更新时间。 + """ + __tablename__ = "analysis_report" + + __table_args__ = ( + Index('idx_reports_project_id', 'project_id'), + {'comment': '报表配置表,用于持久化保存用户对某个查询结果的展示配置'} + ) + + report_id = Column( + Integer, + primary_key=True, + index=True, + autoincrement=True, + comment='报表ID' + ) + project_id = Column( + Integer, + ForeignKey("project.project_id", ondelete="CASCADE"), + nullable=False, + comment='所属项目ID' + ) + result_id = Column( + Integer, + ForeignKey("query_result.result_id", ondelete="CASCADE"), + nullable=False, + comment='关联的数据源(查询结果)ID' + ) + + name = Column( + String(100), + nullable=False, + comment='报表名称' + ) + description = Column( + Text, + nullable=True, + comment='报表描述' + ) + chart_type = Column( + String(50), + default="table", + comment='图表类型 (bar, line, pie, etc.)' + ) + chart_config = Column( + JSON, + nullable=True, + comment='图表配置' + ) + + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='创建时间' + ) + updated_at = Column( + DateTime(timezone=True), + onupdate=func.now(), + comment='更新时间' + ) + + # 关联关系 + # project = relationship("Project", back_populates="reports") # 需在Project model中添加对应关系 + # query_result = relationship("QueryResult") # 需在QueryResult model中添加对应关系 \ No newline at end of file diff --git a/src/backend/app/models/session.py b/src/backend/app/models/session.py new file mode 100644 index 0000000..70a6258 --- /dev/null +++ b/src/backend/app/models/session.py @@ -0,0 +1,82 @@ +""" +会话模型。 + +本模块定义了用于存储用户聊天会话上下文的 ORM 模型。 +""" + +# backend/app/models/session.py + +from sqlalchemy import Column, String, CheckConstraint, Index, Integer, ForeignKey, DateTime +from sqlalchemy.sql import func +from core.database import Base +from sqlalchemy.orm import relationship + +class Session(Base): + """ + 会话表 ORM 模型。 + + 存储用户与 AI 的对话上下文。 + + Attributes: + session_id (int): 会话ID。 + project_id (int): 关联的项目ID。 + session_name (str): 会话名称。 + current_model (str): 当前会话偏好的AI模型ID (新增)。 + created_at (datetime): 创建时间。 + last_activity (datetime): 最后活动时间。 + """ + __tablename__ = 'session' + + __table_args__ = ( + Index('idx_sessions_project_id', 'project_id'), + Index('idx_sessions_last_activity', 'last_activity'), + {'comment': '会话表,存储用户与AI的对话上下文'} + ) + + session_id = Column( + Integer, + autoincrement=True, + primary_key=True, + comment='会话ID' + ) + project_id = Column( + Integer, + ForeignKey('project.project_id', ondelete='CASCADE'), + nullable=False, + comment='关联的项目ID' + ) + session_name = Column( + String(100), + nullable=False, + default='New Session', + comment='会话名称' + ) + + # 【新增字段】记录当前会话使用的模型 + current_model = Column( + String(50), + nullable=True, + comment="当前会话偏好的AI模型ID" + ) + + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='创建时间' + ) + last_activity = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=True, + comment='最后活动时间' + ) + + # 关联关系 + # 反向关联:让 Session 知道它属于哪个 Project + project = relationship("Project", back_populates="sessions") + + # messages = relationship("Message", back_populates="session", cascade="all, delete-orphan") # 如果你有 Message 模型的话 + + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/system_announcement.py b/src/backend/app/models/system_announcement.py new file mode 100644 index 0000000..525a7c9 --- /dev/null +++ b/src/backend/app/models/system_announcement.py @@ -0,0 +1,77 @@ +""" +系统公告模型。 + +本模块定义了用于存储系统公告信息的 ORM 模型。 +""" + +# backend/app/models/system_announcement.py + +from sqlalchemy import Column, Text, String, Integer, Index, CheckConstraint, ForeignKey, DateTime +from sqlalchemy.sql import func +from core.database import Base + +class SystemAnnouncement(Base): + """ + 系统公告表 ORM 模型。 + + 存储管理员发布的系统通知。 + + Attributes: + announcement_id (int): 公告唯一ID。 + title (str): 公告标题。 + content (str): 公告内容。 + status (str): 公告状态:draft/published/unpublished/expired。 + created_by (int): 创建人(管理员user_id)。 + created_at (datetime): 公告发布时间。 + updated_at (datetime): 最后更新时间。 + """ + __tablename__ = 'system_announcement' + + __table_args__ = ( + CheckConstraint("status IN ('draft', 'published', 'unpublished', 'expired')", name='ck_announcement_status'), + Index('idx_announcements_created_by', 'created_by'), + {'comment': '系统公告表,存储管理员发布的系统通知'} + ) + + announcement_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='公告唯一ID' + ) + title = Column( + String(200), + nullable=False, + comment='公告标题' + ) + content = Column( + Text, + nullable=False, + comment='公告内容' + ) + status = Column( + String(20), + default='draft', + nullable=False, + comment='公告状态:draft(草稿)、published(已发布)、unpublished(已下架)、expired(已过期)' + ) + created_by = Column( + Integer, + ForeignKey('user_account.user_id', ondelete='RESTRICT'), + nullable=False, + comment='创建人(管理员user_id)' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='公告发布时间' + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=True, + comment='最后更新时间' + ) + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/unban_request.py b/src/backend/app/models/unban_request.py new file mode 100644 index 0000000..a905e5a --- /dev/null +++ b/src/backend/app/models/unban_request.py @@ -0,0 +1,100 @@ +""" +解封申请模型。 + +本模块定义了用于存储用户账号解封申请的 ORM 模型。 +""" + +# backend/app/models/unban_request.py + +from sqlalchemy import Column, Integer, DateTime, Text, ForeignKey, CheckConstraint, Index +from sqlalchemy.sql import func + +from core.database import Base + +class UnbanRequest(Base): + """ + 用户解封申请表 ORM 模型。 + + Attributes: + request_id (int): 申请ID。 + user_id (int): 申请用户ID。 + ban_log_id (int): 关联的封禁日志ID。 + request_time (datetime): 申请时间。 + reason (str): 申请解封理由。 + supporting_evidence (str): 支持证据。 + status (str): 申请状态:pending/approved/rejected/processed。 + admin_user_id (int): 处理管理员ID。 + decision_time (datetime): 处理时间。 + decision_reason (str): 处理结果。 + created_at (datetime): 记录创建时间。 + """ + __tablename__ = "unban_request" + + __table_args__ = ( + CheckConstraint("status IN ('pending', 'approved', 'rejected', 'processed')"), + Index("idx_unban_requests_user", "user_id"), + Index("idx_unban_requests_time", "request_time"), + {'comment': '用户解封申请表'} + ) + + request_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='申请ID' + ) + user_id = Column( + Integer, + ForeignKey('user_account.user_id', ondelete='CASCADE'), + nullable=False, + comment='申请用户ID' + ) + ban_log_id = Column( + Integer, + ForeignKey('user_ban_log.log_id', ondelete='CASCADE'), + nullable=False, + comment='关联的封禁日志ID' + ) + request_time = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment='申请时间' + ) + reason = Column( + Text, + nullable=False, + comment='申请解封理由' + ) + supporting_evidence = Column( + Text, + comment='支持证据' + ) + status = Column( + Text, + default='pending', + nullable=False, + comment='申请状态:pending/approved/rejected/processed' + ) + admin_user_id = Column( + Integer, + # users -> user_account + ForeignKey('user_account.user_id', ondelete='SET NULL'), + comment='处理管理员ID' + ) + decision_time = Column( + DateTime(timezone=True), + comment='处理时间' + ) + decision_reason = Column( + Text, + comment='处理结果' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment='记录创建时间' + ) + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/user_account.py b/src/backend/app/models/user_account.py new file mode 100644 index 0000000..806efa3 --- /dev/null +++ b/src/backend/app/models/user_account.py @@ -0,0 +1,107 @@ +""" +用户账户模型。 + +本模块定义了用于存储用户基本信息和状态的 ORM 模型。 +""" + +# backend/app/models/user_account.py + +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, CheckConstraint, Index +from sqlalchemy.sql import func +from core.database import Base + +class UserAccount(Base): + """ + 用户账户表 ORM 模型。 + + 存储所有注册用户的基本信息。 + + Attributes: + user_id (int): 用户唯一ID。 + username (str): 用户名。 + email (str): 注册邮箱。 + password_hash (str): 加密后的密码。 + status (str): 账户状态:normal/suspended/banned。 + used_databases (int): 已使用的数据库项目数。 + avatar_url (str): 头像图片URL。 + is_admin (bool): 是否为管理员。 + max_databases (int): 允许创建的最大数据库项目数。 + last_login_at (datetime): 最后登录时间。 + created_at (datetime): 注册时间。 + """ + __tablename__ = 'user_account' + + __table_args__ = ( + CheckConstraint("status IN ('normal', 'suspended', 'banned')", name='ck_user_status'), + Index('idx_user_account_status', 'status'), + Index('idx_user_account_email', 'email'), + {'comment': '用户主表,存储所有注册用户的基本信息'} + ) + + user_id = Column( + Integer, + autoincrement=True, + primary_key=True, + index=True, + comment='用户唯一ID' + ) + + username = Column( + String(50), + unique=True, + index=True, + nullable=False, + comment='用户名' + ) + email = Column( + String(100), + unique=True, + index=True, + nullable=False, + comment='注册邮箱' + ) + password_hash = Column( + String(255), + nullable=False, + comment='加密后的密码' + ) + status = Column( + Text, + default='normal', + nullable=False, + comment='账户状态:normal/suspended/banned' + ) + used_databases = Column( + Integer, + default=0, + nullable=False, + comment='已使用的数据库项目数' + ) + avatar_url = Column( + Text, + comment='头像图片URL' + ) + is_admin = Column( + Boolean, + default=False, + comment='是否为管理员' + ) + max_databases = Column( + Integer, + default=10, + comment='允许创建的最大数据库项目数' + ) + last_login_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + comment='最后登录时间' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + comment='注册时间' + ) + + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/user_ban_log.py b/src/backend/app/models/user_ban_log.py new file mode 100644 index 0000000..76764f1 --- /dev/null +++ b/src/backend/app/models/user_ban_log.py @@ -0,0 +1,91 @@ +""" +封禁日志模型。 + +本模块定义了用于存储用户账号封禁操作记录的 ORM 模型。 +""" + +# backend/app/models/user_ban_log.py + +from sqlalchemy import Column, Integer, Text, CheckConstraint, DateTime, ForeignKey, Index +from sqlalchemy.sql import func +from sqlalchemy.dialects.postgresql import INTERVAL + +from core.database import Base + +class UserBanLog(Base): + """ + 封禁操作日志表 ORM 模型。 + + Attributes: + log_id (int): 日志ID。 + target_user_id (int): 被封禁的用户ID。 + admin_user_id (int): 执行操作的管理员ID。 + action_type (str): 操作类型:ban/unban/warning。 + reason (str): 封禁/解封原因。 + duration (interval): 封禁持续时间。 + effective_time (datetime): 生效时间。 + expiry_time (datetime): 过期时间。 + created_at (datetime): 记录创建时间。 + """ + __tablename__ = "user_ban_log" + + __table_args__ = ( + CheckConstraint("action_type IN ('ban', 'unban', 'warning')"), + Index("idx_ban_logs_target_user", "target_user_id"), + Index("idx_ban_logs_admin_user", "admin_user_id"), + Index("idx_ban_logs_time", "effective_time"), + {"comment": "封禁操作日志表"} + ) + + log_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment="日志ID" + ) + target_user_id = Column( + Integer, + ForeignKey("user_account.user_id", ondelete="CASCADE"), + nullable=False, + comment="被封禁的用户ID" + ) + admin_user_id = Column( + Integer, + ForeignKey("user_account.user_id", ondelete="RESTRICT"), + nullable=False, + comment="执行操作的管理员ID" + ) + action_type = Column( + Text, + nullable=False, + comment="操作类型:ban/unban/warning" + ) + reason = Column( + Text, + nullable=False, + comment="封禁/解封原因" + ) + duration = Column( + INTERVAL, + nullable=True, + comment="封禁持续时间" + ) + effective_time = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="生效时间" + ) + expiry_time = Column( + DateTime(timezone=True), + nullable=True, + comment="过期时间" + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment="记录创建时间" + ) + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/user_login_history.py b/src/backend/app/models/user_login_history.py new file mode 100644 index 0000000..25b80d5 --- /dev/null +++ b/src/backend/app/models/user_login_history.py @@ -0,0 +1,98 @@ +""" +登录历史模型。 + +本模块定义了用于存储用户登录和登出活动的 ORM 模型。 +""" + +# backend/app/models/user_login_history.py + +from sqlalchemy import Column, Text, String, Integer, Boolean, Index, CheckConstraint, ForeignKey, DateTime +from sqlalchemy.dialects.postgresql import INTERVAL, JSONB +from sqlalchemy.sql import func +from core.database import Base + +class UserLoginHistory(Base): + """ + 用户登录登出历史记录表 ORM 模型。 + + Attributes: + login_id (int): 登录记录ID。 + user_id (int): 用户ID。 + login_time (datetime): 登录时间。 + logout_time (datetime): 登出时间。 + session_duration (interval): 会话持续时间。 + ip_address (str): 登录IP地址。 + user_agent (str): 浏览器/设备信息。 + login_status (str): 登录状态:success/failed/expired/forced_logout。 + failure_reason (str): 失败原因(仅当失败时)。 + device_info (dict): 详细设备信息(JSON格式)。 + created_at (datetime): 记录创建时间。 + """ + __tablename__ = "user_login_history" + + __table_args__ = ( + CheckConstraint("login_status IN ('success', 'failed', 'expired', 'forced_logout')", name='user_login_history_login_status_check'), + Index('idx_login_history_user_id', 'user_id'), + Index('idx_login_history_time_range', 'login_time'), + Index('idx_login_history_ip', 'ip_address'), + {'comment': '用户登录登出历史记录表'} + ) + + login_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='登录记录ID' + ) + user_id = Column( + Integer, + ForeignKey('user_account.user_id', ondelete='CASCADE'), + nullable=True, + comment='用户ID' + ) + login_time = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment='登录时间' + ) + logout_time = Column( + DateTime(timezone=True), + nullable=True, + comment='登出时间' + ) + session_duration = Column( + INTERVAL, + nullable=True, + comment='会话持续时间' + ) + ip_address = Column( + String(45), + nullable=False, + comment='登录IP地址' + ) + user_agent = Column( + Text, + comment='浏览器/设备信息' + ) + login_status = Column( + Text, + nullable=False, + comment='登录状态:success/failed/expired/forced_logout' + ) + failure_reason = Column( + Text, + comment='失败原因(仅当失败时)' + ) + device_info = Column( + JSONB, + comment='详细设备信息(JSON格式)' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment='记录创建时间' + ) + class Config: + from_attributes = True diff --git a/src/backend/app/models/user_profile_change_log.py b/src/backend/app/models/user_profile_change_log.py new file mode 100644 index 0000000..027803b --- /dev/null +++ b/src/backend/app/models/user_profile_change_log.py @@ -0,0 +1,83 @@ +""" +资料变更日志模型。 + +本模块定义了用于记录用户个人资料修改历史的 ORM 模型。 +""" + +# backend/app/models/user_profile_change_log.py + +from sqlalchemy import Column, Integer, CheckConstraint, Text, DateTime, String, ForeignKey, Index +from sqlalchemy.sql import func + +from core.database import Base + +class UserProfileChangeLog(Base): + """ + 用户资料变更历史记录表 ORM 模型。 + + Attributes: + change_id (int): 变更ID。 + user_id (int): 被修改的用户ID。 + change_type (str): 变更类型。 + old_value (str): 变更前的值。 + new_value (str): 变更后的值。 + created_at (datetime): 变更时间。 + ip_address (str): 操作IP地址。 + user_agent (str): 操作设备信息。 + """ + __tablename__ = "user_profile_change_log" + + __table_args__ = ( + CheckConstraint("change_type IN ('username', 'email', 'password', 'avatar', 'max_databases')"), + Index("idx_change_logs_user_id", "user_id"), + Index("idx_change_logs_type", "change_type"), + Index("idx_change_logs_time", "created_at"), + {'comment': '用户资料变更历史记录表'} + ) + + change_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='变更ID' + ) + user_id = Column( + Integer, + # users -> user_account + ForeignKey("user_account.user_id", ondelete="CASCADE"), + nullable=False, + comment='被修改的用户ID' + ) + change_type = Column( + Text, + nullable=False, + comment='变更类型' + ) + old_value = Column( + Text, + nullable=True, + comment='变更前的值' + ) + new_value = Column( + Text, + nullable=False, + comment='变更后的值' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment='变更时间' + ) + ip_address = Column( + String(45), + nullable=False, + comment='操作IP地址' + ) + user_agent = Column( + Text, + nullable=False, + comment='操作设备信息' + ) + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/models/violation_log.py b/src/backend/app/models/violation_log.py new file mode 100644 index 0000000..423dfbd --- /dev/null +++ b/src/backend/app/models/violation_log.py @@ -0,0 +1,116 @@ +""" +违规日志模型。 + +本模块定义了用于记录用户违规行为和处理结果的 ORM 模型。 +""" + +# backend/app/models/violation_log.py + +from sqlalchemy import Column, Text, String, Integer, DateTime, CheckConstraint, Index, ForeignKey +from sqlalchemy.sql import func + +from core.database import Base + +class ViolationLog(Base): + """ + 违规行为日志表 ORM 模型。 + + Attributes: + violation_id (int): 违规行为ID。 + user_id (int): 违规用户ID。 + event_type (str): 违规事件类型。 + event_description (str): 事件详细描述。 + risk_level (str): 风险等级:LOW/MEDIUM/HIGH/CRITICAL。 + ip_address (str): 违规操作IP地址。 + client_user_agent (str): 客户端用户代理信息。 + request_content (str): 原始请求内容。 + handled_by (int): 处理人ID。 + handled_at (datetime): 处理时间。 + resolution_status (str): 处理状态:pending/resolved/rejected。 + created_at (datetime): 记录创建时间。 + """ + __tablename__ = "violation_log" + + __table_args__ = ( + CheckConstraint("""event_type IN ( + 'excessive_api_usage', + 'multiple_failed_logins', + 'frequent_remote_login' + )"""), + CheckConstraint("risk_level IN ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL')"), + Index("idx_violation_logs_user_id", "user_id"), + Index("idx_violation_logs_time", "created_at"), + Index("idx_violation_logs_status", "resolution_status"), + Index("idx_violation_logs_risk_level", "risk_level"), + {'comment': '违规行为日志表'} + ) + + violation_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment='违规行为ID' + ) + user_id = Column( + Integer, + # users -> user_account + ForeignKey('user_account.user_id', ondelete='CASCADE'), + nullable=False, + comment='违规用户ID' + ) + event_type = Column( + Text, + nullable=False, + comment='违规事件类型' + ) + event_description = Column( + Text, + nullable=False, + comment='事件详细描述' + ) + risk_level = Column( + Text, + nullable=False, + comment='风险等级:LOW/MEDIUM/HIGH/CRITICAL' + ) + ip_address = Column( + String(45), + nullable=False, + comment='违规操作IP地址' + ) + client_user_agent = Column( + Text, + nullable=True, + comment='客户端用户代理信息' + ) + request_content = Column( + Text, + nullable=True, + comment='原始请求内容' + ) + handled_by = Column( + Integer, + # :users -> user_account + ForeignKey('user_account.user_id', ondelete='SET NULL'), + nullable=True, + comment='处理人ID' + ) + handled_at = Column( + DateTime(timezone=True), + nullable=True, + comment='处理时间' + ) + resolution_status = Column( + Text, + default='pending', + nullable=False, + comment='处理状态:pending/resolved/rejected' + ) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + comment='记录创建时间' + ) + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/mysql/__init__.py b/src/backend/app/mysql/__init__.py new file mode 100644 index 0000000..d0364bc --- /dev/null +++ b/src/backend/app/mysql/__init__.py @@ -0,0 +1,7 @@ +""" +MySQL 模块初始化。 + +提供 MySQL 数据库连接、执行、转换和安全校验等核心功能。 +""" + +# backend/app/mysql/__init__.py diff --git a/src/backend/app/mysql/mysql_converter.py b/src/backend/app/mysql/mysql_converter.py new file mode 100644 index 0000000..9a9fb16 --- /dev/null +++ b/src/backend/app/mysql/mysql_converter.py @@ -0,0 +1,532 @@ +""" +MySQL 转换器。 + +专门用于将 SQLite 数据库模式和 SQL 语句深度转换为 MySQL 兼容格式。 +""" + +# backend/app/mysql/mysql_converter.py + +from typing import Dict, Any, List +from core.sql_dialect_converter import SQLDialectConverter, SQLConversionError + +import re +import sqlglot +from sqlglot import exp, parse_one +from typing import Dict, List, Optional, Tuple, Union, Callable +from collections import defaultdict + +# 动态导入表达式类型 (解决版本兼容问题) +try: + # sqlglot 24.0+ 使用新结构 + from sqlglot.expressions import ( + CreateTable as CreateTableExpr, + ColumnDef as ColumnDefExpr, + ForeignKey as ForeignKeyExpr, + PrimaryKey as PrimaryKeyExpr, + DataType as DataTypeExpr, + Properties as PropertiesExpr, + Property as PropertyExpr, + Literal as LiteralExpr, + Var as VarExpr, + OnDelete as OnDeleteExpr, + OnUpdate as OnUpdateExpr + ) +except ImportError: + # 旧版本回退 + CreateTableExpr = type('CreateTable', (), {'key': 'create_table'}) + ColumnDefExpr = type('ColumnDef', (), {'key': 'column_def'}) + ForeignKeyExpr = type('ForeignKey', (), {'key': 'foreign_key'}) + PrimaryKeyExpr = type('PrimaryKey', (), {'key': 'primary_key'}) + DataTypeExpr = exp.DataType + PropertiesExpr = exp.Properties + PropertyExpr = exp.Property + LiteralExpr = exp.Literal + VarExpr = exp.Var + OnDeleteExpr = exp.Delete + OnUpdateExpr = exp.Update + +class MySQLConverter: + """ + SQLite到MySQL深度转换器 (完全兼容 sqlglot 24.0+) + 专为模式转换优化,精准处理保留字/主键/精度/外键/字符集等关键问题 + """ + + # MySQL保留字集合 (来自MySQL 8.0官方文档) + MYSQL_RESERVED_WORDS = { + "user", "order", "group", "key", "index", "system", "option", "status", + "current_date", "current_time", "current_timestamp", "database", "schema", + "table", "view", "procedure", "function", "trigger", "event", "partition" + } + + # 智能字段映射规则 (列名 -> 精度规则) + FIELD_PRECISION_RULES = { + # 价格/金额字段 + r"(?i)(price|amount|cost|fee|total|balance|discount)": ("DECIMAL", (10, 2)), + # 数量/计数字段 + r"(?i)(quantity|count|num|stock|capacity)": ("INT", None), + # 折扣率/百分比字段 + r"(?i)(discount|rate|percentage|ratio|probability)": ("DECIMAL", (5, 4)), + # 时长/时间字段 + r"(?i)(duration|time_span|hours|minutes|seconds)": ("DECIMAL", (10, 2)), + # ID/标识符字段 + r"(?i)(id|uuid|guid|code|identifier|ref)": ("VARCHAR", 50), + # 电话/信用卡等特殊字段 + r"(?i)(phone|telephone|contact_number)": ("VARCHAR", 20), + r"(?i)(credit_card|card_number)": ("CHAR", 19), + # 通用文本字段 (主键/外键相关) + r"(?i)(name|type|category|status|preference)": ("VARCHAR", 100), + } + + # 默认VARCHAR长度 (主键/索引列) + DEFAULT_VARCHAR_LENGTH = 191 # utf8mb4索引最大长度 + + def __init__(self): + """ + 初始化转换器。 + + 设置基础 SQL 方言转换器、启用深度转换并初始化统计计数器。 + 同时建立 AST 节点类型与转换方法的映射关系。 + """ + self.generic_converter = SQLDialectConverter() + self.deep_conversion_enabled = True + self.conversion_stats = defaultdict(int) + + # 重构转换器映射 (使用节点key而非类类型) + self.ast_transformers = { + "create_table": self._transform_create_table, + "foreign_key": self._transform_foreign_key, + "primary_key": self._transform_primary_key, + "column_def": self._transform_column_def, + } + + def _apply_ast_transformations(self, ast: exp.Expression): + """ + 递归应用 AST 转换规则 (兼容 sqlglot 新旧版本)。 + + 遍历抽象语法树 (AST) 的每个节点,根据节点类型 (如 create_table, foreign_key) + 调用相应的转换方法 (`_transform_xxx`) 进行深度修改。 + + Args: + ast (exp.Expression): 待转换的 SQL 抽象语法树根节点。 + """ + # 遍历所有节点 + for node in ast.walk(): + # 获取节点类型标识 (兼容新旧版本) + node_key = getattr(node, "key", None) + if not node_key and hasattr(node, "__class__"): + node_key = node.__class__.__name__.lower() + + # 应用匹配的转换器 + transformer = self.ast_transformers.get(node_key) + if transformer: + try: + transformer(node) + except Exception as e: + print(f"转换节点 {node_key} 时出错: {str(e)}") + + # 全局表属性修复 (必须最后执行) + for node in ast.walk(): + node_key = getattr(node, "key", None) or node.__class__.__name__.lower() + if node_key == "create_table": + self._add_table_properties(node) + + def _transform_create_table(self, node): + """ + 转换 CREATE TABLE 语句。 + + 主要功能: + 1. 检查表名是否为 MySQL 保留字(如 user, group, order 等)。 + 2. 如果是保留字,则使用反引号 (`) 包裹表名,避免语法错误。 + + Args: + node: create_table 类型的 AST 节点。 + """ + # 获取表名 (兼容新旧结构) + table_name = None + if hasattr(node, "this") and hasattr(node.this, "name"): + table_name = node.this.name.lower() + elif hasattr(node, "expression") and hasattr(node.expression, "name"): + table_name = node.expression.name.lower() + + if table_name and table_name in self.MYSQL_RESERVED_WORDS: + # 用反引号包裹表名 + new_name = f"`{node.this.name}`" if hasattr(node, "this") else f"`{node.expression.name}`" + if hasattr(node, "this"): + node.this.set("this", new_name) + else: + node.expression.set("this", new_name) + self.conversion_stats["reserved_word_fixes"] += 1 + + def _transform_primary_key(self, node): + """ + 修复主键定义。 + + SQLite 允许主键为 TEXT 类型,但在 MySQL 中主键索引有长度限制(InnoDB utf8mb4 默认为 191 字符)。 + 此方法会查找 TEXT 类型的主键列,并将其类型强制转换为 VARCHAR(191)。 + + Args: + node: primary_key 类型的 AST 节点。 + """ + # 获取主键列表达式 + pk_columns = [] + if hasattr(node, "expressions"): + pk_columns = node.expressions + elif hasattr(node, "args") and "expressions" in node.args: + pk_columns = node.args["expressions"] + + # 检查主键列 + for col_expr in pk_columns: + col_name = None + if hasattr(col_expr, "name"): + col_name = col_expr.name + elif hasattr(col_expr, "this") and hasattr(col_expr.this, "name"): + col_name = col_expr.this.name + + if not col_name: + continue + + # 向上查找表定义 + table_node = node.find_ancestor(lambda n: getattr(n, "key", "") == "create_table") + if not table_node: + continue + + # 查找列定义 + for col_def in table_node.find_all(lambda n: getattr(n, "key", "") == "column_def"): + if hasattr(col_def, "this") and col_def.this.name == col_name: + # 检查类型 + col_type = None + if hasattr(col_def, "kind"): + col_type = col_def.kind + elif hasattr(col_def, "args") and "kind" in col_def.args: + col_type = col_def.args["kind"] + + if col_type and hasattr(col_type, "this") and col_type.this == "TEXT": + # 替换为 VARCHAR(191) + new_type = DataTypeExpr.build(f"VARCHAR({self.DEFAULT_VARCHAR_LENGTH})") + if hasattr(col_def, "kind"): + col_def.set("kind", new_type) + else: + col_def.args["kind"] = new_type + self.conversion_stats["text_primary_key_fixes"] += 1 + + def _transform_column_def(self, node): + """ + 智能修复列定义。 + + 包含两个主要修复逻辑: + 1. **TEXT 主键修复**:如果列定义中包含主键约束且类型为 TEXT,转为 VARCHAR(191)。 + 2. **智能精度分配**:针对 DECIMAL/NUMERIC 类型,根据列名模式(如 price, rate)自动匹配 + 合适的精度(如 DECIMAL(10,2)),避免 MySQL 默认精度可能导致的精度丢失问题。 + + Args: + node: column_def 类型的 AST 节点。 + """ + col_name = node.this.name.lower() if hasattr(node, "this") else "" + + # 1. 修复TEXT主键 (列级主键) + if hasattr(node, "constraints"): + has_primary_key_constraint = False + for constraint in node.constraints: + if getattr(constraint, "kind", None) == "primary": + has_primary_key_constraint = True + col_type = getattr(node, "kind", None) + if col_type and getattr(col_type, "this", None) == "TEXT": + new_type = DataTypeExpr.build(f"VARCHAR({self.DEFAULT_VARCHAR_LENGTH})") + node.set("kind", new_type) + self.conversion_stats["text_primary_key_fixes"] += 1 + break # 修复一次就够了 + + # 如果有主键约束但类型不是TEXT,则不需要额外处理 + if has_primary_key_constraint: + return + + # 2. 智能精度分配 + col_type = getattr(node, "kind", None) + if col_type and getattr(col_type, "this", None) in ("NUMERIC", "DECIMAL"): + for pattern, (data_type, precision) in self.FIELD_PRECISION_RULES.items(): + if re.search(pattern, col_name): + # 应用精度规则 + if data_type == "INT": + new_type = DataTypeExpr.Type.INT + elif data_type == "DECIMAL" and precision: + new_type = DataTypeExpr.build(f"DECIMAL({precision[0]},{precision[1]})") + elif data_type in ("VARCHAR", "CHAR") and precision: + new_type = DataTypeExpr.build(f"{data_type}({precision})") + else: + new_type = DataTypeExpr.build("DECIMAL(10,2)") + + node.set("kind", new_type) + self.conversion_stats[f"precision_fix_{col_name}"] += 1 + break + else: + # 默认精度规则 + node.set("kind", DataTypeExpr.build("DECIMAL(10,2)")) + self.conversion_stats["default_precision_fixes"] += 1 + + def _transform_foreign_key(self, node): + """ + 添加缺失的外键级联规则。 + + SQLite 往往省略外键行为,而 MySQL 需要显式指定。 + 如果外键定义中缺少 ON DELETE 或 ON UPDATE 规则,此方法会默认添加 CASCADE 级联规则, + 确保数据完整性。 + + Args: + node: foreign_key 类型的 AST 节点。 + """ + # 检查是否已有ON DELETE/UPDATE + has_on_delete = False + has_on_update = False + + if hasattr(node, "expressions"): + for expr in node.expressions: + if isinstance(expr, OnDeleteExpr): + has_on_delete = True + elif isinstance(expr, OnUpdateExpr): + has_on_update = True + + # 添加缺失的级联规则 + if not has_on_delete: + on_delete = OnDeleteExpr(this=VarExpr(this="CASCADE")) + if hasattr(node, "append"): + node.append("expressions", on_delete) + else: + node.expressions.append(on_delete) + self.conversion_stats["fk_cascade_additions"] += 1 + + if not has_on_update: + on_update = OnUpdateExpr(this=VarExpr(this="CASCADE")) + if hasattr(node, "append"): + node.append("expressions", on_update) + else: + node.expressions.append(on_update) + self.conversion_stats["fk_cascade_additions"] += 1 + + def _add_table_properties(self, node): + """ + 添加 InnoDB 引擎和 utf8mb4 字符集。 + + 强制所有表使用 InnoDB 引擎和 utf8mb4 字符集,以支持事务和完整的 Unicode 字符(如 Emoji)。 + 这是建表语句转换的最后一步。 + + Args: + node: create_table 类型的 AST 节点。 + """ + # 检查是否已有ENGINE属性 + has_engine = False + properties = getattr(node, "properties", None) or getattr(node, "args", {}).get("properties") + + if properties and hasattr(properties, "expressions"): + for prop in properties.expressions: + if hasattr(prop, "this") and str(prop.this).upper() == "ENGINE": + has_engine = True + + if has_engine: + return + + # 创建属性列表 + new_properties = [ + PropertyExpr(this="ENGINE", value=LiteralExpr.string("InnoDB")), + PropertyExpr(this="DEFAULT CHARACTER SET", value=LiteralExpr.string("utf8mb4")), + PropertyExpr(this="COLLATE", value=LiteralExpr.string("utf8mb4_unicode_ci")), + ] + + # 创建Properties节点 + prop_expr = PropertiesExpr(expressions=new_properties) + + # 设置属性 (兼容新旧结构) + if hasattr(node, "set"): + node.set("properties", prop_expr) + else: + node.args["properties"] = prop_expr + + self.conversion_stats["engine_charset_additions"] += 1 + + def convert_schema(self, sqlite_sql: str) -> str: + """ + 深度转换 SQLite 表结构定义到 MySQL 格式。 + + 结合了通用方言转换(基于 sqlglot 默认规则)和自定义 AST 深度转换(`_apply_ast_transformations`), + 生成生产级可用的 MySQL DDL 语句。 + + Args: + sqlite_sql (str): SQLite 格式的 DDL 语句。 + + Returns: + str: 转换后的 MySQL 格式 DDL 语句。 + """ + try: + # 基础转换 + mysql_sql = self.generic_converter.convert( + sqlite_sql, 'sqlite', 'mysql', pretty=True + ) + + # AST深度转换 (仅当启用时) + if self.deep_conversion_enabled: + try: + # 关键修复: 使用正确的read方言 + ast = parse_one(mysql_sql, read="mysql", dialect="mysql") + self._apply_ast_transformations(ast) + mysql_sql = ast.sql(dialect="mysql", pretty=True) + except Exception as ast_err: + self.conversion_stats["ast_conversion_failures"] += 1 + print(f"AST转换失败,回退到基础转换: {str(ast_err)}") + + # 兜底修复 + mysql_sql = self._apply_fallback_fixes(mysql_sql) + return mysql_sql + except Exception as e: + raise SQLConversionError(f"SQLite到MySQL模式转换失败: {str(e)}") + + def _apply_fallback_fixes(self, sql: str) -> str: + """字符串级兜底修复 (AST转换失败时使用)""" + # 1. 修复TEXT主键 (简单模式) + # 移除重复的PRIMARY KEY定义,只保留列级别的 + sql = re.sub( + r'(^\s*CREATE\s+TABLE\s+\w+\s*$$[^$$]*?),\s*PRIMARY\s+KEY\s*$$[^$$]*?$$', + r'\1', + sql, + flags=re.IGNORECASE | re.MULTILINE | re.DOTALL + ) + + # 将TEXT类型主键转换为VARCHAR并保留PRIMARY KEY约束 + sql = re.sub( + r'(\w+)\s+TEXT\s+NOT NULL', + lambda m: f"{m.group(1)} VARCHAR({self.DEFAULT_VARCHAR_LENGTH}) NOT NULL", + sql, + flags=re.IGNORECASE + ) + + # 2. 修复NUMERIC精度 (通用规则) + sql = re.sub( + r'(\w+)\s+NUMERIC\s*', + lambda m: f"{m.group(1)} DECIMAL(10,2)", + sql, + flags=re.IGNORECASE + ) + + # 3. 添加表选项 (如果缺失) + if not re.search(r'ENGINE\s*=', sql, re.IGNORECASE): + sql = re.sub( + r'\)\s*;', + r') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;', + sql, + flags=re.IGNORECASE + ) + + return sql + + def convert_statement(self, sqlite_sql: str) -> str: + """ + 转换SQLite SQL语句到MySQL格式 + + Args: + sqlite_sql: SQLite的SQL语句 + + Returns: + MySQL兼容的SQL语句 + """ + try: + # 基础转换 + mysql_sql = self.generic_converter.convert( + sqlite_sql, 'sqlite', 'mysql', pretty=False + ) + + # 仅对DDL语句进行深度转换 + if any(keyword in mysql_sql.upper() for keyword in ["CREATE TABLE", "ALTER TABLE"]): + return self.convert_schema(mysql_sql) # 复用schema转换 + + # DML/其他语句 - 应用轻量级修复 + return self._apply_statement_fixes(mysql_sql) + except Exception as e: + raise SQLConversionError(f"SQLite到MySQL语句转换失败: {str(e)}") + + def _apply_statement_fixes(self, sql: str) -> str: + """轻量级语句修复 (DML/其他语句)""" + # 1. 修复布尔值 + sql = sql.replace('TRUE', '1').replace('FALSE', '0') + + # 2. 修复日期函数 + sql = re.sub( + r"strftime\('%Y-%m-%d %H:%M:%S',\s*(\w+)\)", + r"DATE_FORMAT(\1, '%Y-%m-%d %H:%i:%s')", + sql, + flags=re.IGNORECASE + ) + + # 3. 修复AUTOINCREMENT + sql = sql.replace('AUTOINCREMENT', 'AUTO_INCREMENT') + + return sql + + def batch_convert(self, sqlite_statements: List[str]) -> List[str]: + """ + 批量转换SQLite语句到MySQL格式 (带错误隔离) + + Args: + sqlite_statements: SQLite语句列表 + + Returns: + 转换结果列表 (失败语句包含错误注释) + """ + mysql_statements = [] + for i, statement in enumerate(sqlite_statements): + stmt = statement.strip() + if not stmt: + continue + + try: + # 智能判断语句类型 + if "CREATE TABLE" in stmt.upper() or "ALTER TABLE" in stmt.upper(): + converted = self.convert_schema(stmt) + else: + converted = self.convert_statement(stmt) + + mysql_statements.append(converted) + self.conversion_stats["successful_conversions"] += 1 + except SQLConversionError as e: + self.conversion_stats["conversion_errors"] += 1 + error_annotation = f"-- 转换失败 (语句#{i + 1}): {str(e)}\n" + mysql_statements.append(f"{error_annotation}{stmt}") + except Exception as e: + self.conversion_stats["unexpected_errors"] += 1 + error_annotation = f"-- 未处理错误 (语句#{i + 1}): {str(e)}\n" + mysql_statements.append(f"{error_annotation}{stmt}") + + return mysql_statements + + def get_conversion_stats(self) -> Dict[str, int]: + """获取转换统计信息""" + return dict(self.conversion_stats) + + def disable_deep_conversion(self): + """禁用深度AST转换 (回退到基础模式)""" + self.deep_conversion_enabled = False + + def enable_deep_conversion(self): + """启用深度AST转换""" + self.deep_conversion_enabled = True + + +# 使用示例 +if __name__ == "__main__": + # 示例:SQLite到MySQL的深度转换 + converter = MySQLConverter() + + # SQLite表结构示例 + sqlite_create_table = """CREATE TABLE Hotel (Name TEXT NOT NULL, Address TEXT, Phone TEXT, PRIMARY KEY(Name)); +CREATE TABLE Room (Type TEXT NOT NULL, Price NUMERIC, Quantity NUMERIC, PRIMARY KEY(Type)); +CREATE TABLE Booking (BookingID TEXT NOT NULL, RoomType TEXT NOT NULL, StartDate DATETIME, EndDate DATETIME, PRIMARY KEY(BookingID), FOREIGN KEY(RoomType) REFERENCES Room(Type)); +CREATE TABLE User (UserID TEXT NOT NULL, Name TEXT, Contact TEXT, Discount NUMERIC, CreditCard TEXT, PRIMARY KEY(UserID)); +CREATE TABLE Booking_Room (BookingID TEXT NOT NULL, RoomType TEXT NOT NULL, Duration NUMERIC, RoomPreference TEXT, PRIMARY KEY(BookingID, RoomType), FOREIGN KEY(BookingID) REFERENCES Booking(BookingID), FOREIGN KEY(RoomType) REFERENCES Room(Type)); +CREATE TABLE User_Booking (UserID TEXT NOT NULL, BookingID TEXT NOT NULL, PaymentMethod TEXT, LoyaltyProgram TEXT, PRIMARY KEY(UserID, BookingID), FOREIGN KEY(UserID) REFERENCES User(UserID), FOREIGN KEY(BookingID) REFERENCES Booking(BookingID));""" + + for sql in sqlite_create_table.split(';'): + try: + mysql_create_table = converter.convert_schema(sql) + print("SQLite:") + print(sql) + print("\n转换后的MySQL:") + print(mysql_create_table) + except SQLConversionError as e: + print(f"转换失败: {e}") \ No newline at end of file diff --git a/src/backend/app/mysql/mysql_database.py b/src/backend/app/mysql/mysql_database.py new file mode 100644 index 0000000..08e87d8 --- /dev/null +++ b/src/backend/app/mysql/mysql_database.py @@ -0,0 +1,285 @@ +from typing import Optional, List +""" +MySQL 数据库管理器。 + +处理 MySQL 数据库连接、引擎初始化及资源释放,支持 Root 和普通用户连接池管理。 +""" + +# backend/app/mysql/mysql_database.py + +from typing import Optional + +from sqlalchemy import text +from sqlalchemy.engine import url +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from config.base import MySQLConfig +from core.exceptions import InvalidOperationException +from core.log import log +from models import DatabaseInstance + + +class MysqlHelper: + """ + MySQL 数据库连接处理类 (原生 SQL 执行模式)。 + + 设计原则: + 1. 超级用户 (root) 连接仅用于初始化 (创建用户和 DB)。 + 2. 业务 SQL 必须通过普通用户连接执行。 + 3. 严格隔离权限,防止 SQL 注入。 + """ + + _root_engine: Optional[AsyncEngine] = None + _user_engine: dict[int, AsyncEngine] = {} + _user_exist: set[str] = set() + + # 测试有没有连上MySQL + @classmethod + async def test_connection(cls): + """ + 测试 Root 连接是否可用。 + + 执行简单的 `SELECT 1` 语句来验证数据库连通性。 + + Raises: + Exception: 连接失败时抛出异常。 + """ + engine = await cls.get_root_engine() + async with engine.connect() as conn: + await conn.execute(text("select 1")) + + @classmethod + async def init_root_engine(cls, mysql_config: MySQLConfig) -> None: + """ + 初始化 Root 用户引擎。 + + 建立一个具有最高权限的数据库连接池,用于执行 DDL 等管理操作。 + + Args: + mysql_config (MySQLConfig): 从 YAML 配置文件加载的 MySQL 配置对象。 + """ + connect_args = { + "auth_plugin": mysql_config.plugin, + "ssl": mysql_config.ssl + } + + cls._root_engine = create_async_engine( + mysql_config.sqlalchemy_database_url, + connect_args=connect_args, + pool_size=mysql_config.pool_size, + pool_recycle=mysql_config.pool_recycle, + pool_timeout=mysql_config.pool_timeout, + max_overflow=mysql_config.max_overflow + ) + + @classmethod + async def init_user_engine(cls, mysql_config: MySQLConfig, instance_id: int, user_database_url: url): + """ + 初始化普通用户引擎。 + + 为每个具体的数据库实例(Project)建立独立的连接池,使用受限权限的账号。 + + Args: + mysql_config (MySQLConfig): 基础配置。 + instance_id (int): 数据库实例 ID,作为连接池的 Key。 + user_database_url (url): 包含用户名密码的具体连接 URL。 + """ + connect_args = { + "auth_plugin": mysql_config.plugin, + "ssl": mysql_config.ssl + } + + log.info("init user engine: {}", user_database_url) + cls._user_engine[instance_id] = create_async_engine( + user_database_url, + connect_args=connect_args, + pool_size=mysql_config.pool_size, + pool_recycle=mysql_config.pool_recycle, + pool_timeout=mysql_config.pool_timeout, + max_overflow=mysql_config.max_overflow + ) + + @classmethod + async def close_root_engine(cls): + """ + 关闭 Root 引擎。 + + 释放 Root 连接池资源。 + """ + await cls._root_engine.dispose() + cls._root_engine = None + log.info("close root engine") + + @classmethod + async def close_user_engine(cls, database_instance: DatabaseInstance): + """ + 关闭指定的用户引擎。 + + 释放特定数据库实例的连接池资源。 + + Args: + database_instance (DatabaseInstance): 需要关闭的数据库实例对象。 + """ + await cls._user_engine[database_instance.instance_id].dispose() + del cls._user_engine[database_instance.instance_id] + log.info("close user engine: {}", database_instance.instance_id) + + @classmethod + async def close_all_engine(cls): + """ + 关闭所有引擎 + :return: + """ + if cls._root_engine is not None: + await cls.close_root_engine() + + for engine in cls._user_engine.values(): + await engine.dispose() + + @classmethod + async def get_root_engine(cls) -> AsyncEngine: + """ + 获取root用户引擎 + :return: + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + + log.info("get root engine") + return cls._root_engine + + @classmethod + async def get_user_engine(cls, database_instance: DatabaseInstance) -> AsyncEngine: + """ + 获取用户引擎 + :param database_instance: + :return: + """ + if database_instance.instance_id not in cls._user_engine: + raise InvalidOperationException("请先初始化用户引擎") + + log.info("get user engine: {}", database_instance.instance_id) + return cls._user_engine[database_instance.instance_id] + + @classmethod + def is_user_engine_exists(cls, instance_id: int) -> bool: + """ + 判断用户引擎是否存在 + :param instance_id: + :return: + """ + return instance_id in cls._user_engine + + @classmethod + def is_user_exists(cls, db_username: str) -> bool: + """ + 检查_user_exist中是否存在指定用户 + :param db_username: 数据库用户名 + :return: 如果用户存在返回True,否则返回False + """ + + return db_username in cls._user_exist + + @classmethod + async def is_user_exist_in_mysql(cls, db_username: str) -> bool: + """ + 检查数据库中是否存在指定用户 + :param db_username: 待检查的用户名 + :return: 如果用户存在返回True,否则返回False + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + + try: + engine = await cls.get_root_engine() + async with engine.connect() as conn: + # 查询 mysql.user 表,检查是否存在该用户 + # 使用 GRANTEE 格式 'username'@'host' 进行匹配 + result = await conn.execute( + text("SELECT User FROM mysql.user WHERE User = :username AND Host = :host"), + {"username": db_username, "host": "%"} + ) + return result.mappings().fetchone() is not None + except Exception as e: + log.error("检查用户是否存在失败: {}", e) + return False + + @classmethod + def add_user(cls, db_username: str): + """ + 添加用户 + :param db_username: 待添加的用户名 + :return: + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + cls._user_exist.add(db_username) + + @classmethod + async def check_privilege(cls, db_username: str, db_name: str) -> bool: + """ + 检查用户对数据库的读写权限 + :param db_username: 数据库用户名(仅用户名,如 'test_1',无需带@%) + :param db_name: 数据库名称 + :return: 如果用户对数据库有读写权限(SELECT/INSERT/UPDATE/DELETE)返回True,否则返回False + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + + # 定义需要的读写权限列表 + required_privileges = {"SELECT", "INSERT", "UPDATE", "DELETE"} + # 标准化GRANTEE格式(避免手动拼接单引号,防止注入) + grantee = f"'{db_username}'@'%'" + + try: + engine = await cls.get_root_engine() + async with engine.connect() as conn: + # 查询用户对指定数据库的权限(使用SCHEMA_PRIVILEGES表) + result = await conn.execute( + text(""" + SELECT PRIVILEGE_TYPE + FROM information_schema.SCHEMA_PRIVILEGES + WHERE GRANTEE = :grantee + AND TABLE_SCHEMA = :db_name + """), + {"grantee": grantee, "db_name": db_name} + ) + # 提取用户拥有的权限 + user_privileges = {row.PRIVILEGE_TYPE for row in result.fetchall()} + # 判断是否包含至少一种读写权限 + has_write_read_priv = not required_privileges.isdisjoint(user_privileges) + return has_write_read_priv + except Exception as e: + log.error("检查用户[{}]对数据库[{}]的权限时出错: {}", db_username, db_name, e, exc_info=True) + return False + + @classmethod + async def grant_user_privileges(cls, db_name: str, db_username: str): + """ + 为用户授予数据库的读写权限 + :param db_name: 数据库名称 + :param db_username: 数据库用户名(仅用户名,如 'test_1',无需带@%) + :return: + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + + user_hosts: List[str] = ["%", "localhost"] + + escaped_db_name = db_name.replace("`", "``") + escaped_username = db_username.replace("'", "''") + + # 4. 循环授予权限并刷新 + for host in user_hosts: + try: + # 安全拼接GRANT语句(仅转义标识符,避免注入) + grant_sql = ( + f"GRANT ALL PRIVILEGES ON `{escaped_db_name}`.* TO '{escaped_username}'@'{host}'" + ) + async with cls.get_root_engine() as conn: + await conn.execute(text(grant_sql)) + log.info("[MySQL] 成功为用户 '{}'@{} 授予数据库 '{}' 的所有权限", db_username, host, db_name) + except Exception as e: + raise RuntimeError( + f"为用户 '{db_username}'@{host} 授予数据库 '{db_name}' 权限失败: {str(e)}" + ) from e diff --git a/src/backend/app/mysql/mysql_execute.py b/src/backend/app/mysql/mysql_execute.py new file mode 100644 index 0000000..d2541db --- /dev/null +++ b/src/backend/app/mysql/mysql_execute.py @@ -0,0 +1,165 @@ +""" +MySQL 执行器。 + +提供 Root 和普通用户的 SQL 执行接口,包含 DDL、DML 和 DQL 操作,并集成安全校验。 +""" + +# backend/app/mysql/mysql_execute.py + +from sqlalchemy import text + +from core.log import log +from models import DatabaseInstance +from mysql.mysql_database import MysqlHelper +from mysql.mysql_secure import validate_safe_sql +from core.sql_dialect_converter import SQLDialectConverter +from mysql.mysql_converter import MySQLConverter +from core.exceptions import DatabaseOperationFailedException + +# 初始化转换器 +generic_converter = SQLDialectConverter() +mysql_converter = MySQLConverter() + + +async def execute_sql_root(sql: str): + """ + Root 用户执行 SQL (DDL)。 + + 使用 Root 权限执行 SQL 语句,主要用于创建数据库、创建用户等管理操作。 + 执行前会进行 SQL 转换 (SQLite -> MySQL) 和安全检查。 + + Args: + sql (str): 待执行的 SQL 语句 (可能是 SQLite 格式)。 + + Raises: + SQLSecurityException: 如果 SQL 包含被禁止的操作。 + """ + # 将SQL转换为MySQL方言 + try: + mysql_sql = generic_converter.convert(sql, "sqlite", "mysql") + mysql_sql = mysql_converter.convert_statement(mysql_sql) + except Exception as e: + log.warning("SQL转换失败,使用原始SQL: {}", e) + mysql_sql = sql + + validate_safe_sql(mysql_sql, is_root=True) + engine = await MysqlHelper.get_root_engine() + async with engine.connect() as conn: + await conn.execute(text(mysql_sql)) + log.info("root: successfully execute {}", mysql_sql) + +async def execute_dql_root(dql: str): + """ + Root 用户执行 DQL (查询)。 + + 使用 Root 权限执行查询语句,返回字典列表格式的结果。 + + Args: + dql (str): 待执行的查询语句。 + + Returns: + list[dict]: 查询结果列表,每项为一个字典。 + """ + # 将SQL转换为MySQL方言 + mysql_dql = None + try: + mysql_dql = generic_converter.convert(dql, "sqlite", "mysql") + mysql_dql = mysql_converter.convert_statement(mysql_dql) + except Exception as e: + log.warning("SQL转换失败,使用原始SQL: {}", e) + + engine = await MysqlHelper.get_root_engine() + async with engine.connect() as conn: + result = await conn.execute(text(mysql_dql)) + query_result = result.mappings().fetchall() + return [dict(row) for row in query_result] + +async def execute_dql_user(dql: str, database_instance: DatabaseInstance): + """ + 普通用户执行 DQL (查询)。 + + 使用指定数据库实例的普通用户权限执行查询。会自动转换 SQL 方言并进行安全检查。 + + Args: + dql (str): 待执行的查询语句。 + database_instance (DatabaseInstance): 目标数据库实例对象。 + + Returns: + list[dict]: 查询结果列表。 + """ + # 将SQL转换为MySQL方言 + try: + mysql_dql = generic_converter.convert(dql, "sqlite", "mysql") + mysql_dql = mysql_converter.convert_statement(mysql_dql) + except Exception as e: + log.warning("SQL转换失败,使用原始SQL: {}", e) + mysql_dql = dql + + validate_safe_sql(mysql_dql, is_root=False) + engine = await MysqlHelper.get_user_engine(database_instance) + async with engine.connect() as conn: + result = await conn.execute(text(mysql_dql)) + query_result = result.mappings().fetchall() + return [dict(row) for row in query_result] + + +async def execute_dml_user(dml: str, database_instance: DatabaseInstance): + """ + 普通用户执行 DML (增删改)。 + + 使用指定数据库实例的普通用户权限执行数据变更操作。 + 执行后会自动提交事务。 + + Args: + dml (str): 待执行的 DML 语句 (INSERT/UPDATE/DELETE)。 + database_instance (DatabaseInstance): 目标数据库实例对象。 + + Returns: + dict: 包含受影响行数 (rowcount) 和最后插入ID (lastrowid) 的字典。 + """ + # 将SQL转换为MySQL方言 + try: + mysql_dml = generic_converter.convert(dml, "sqlite", "mysql") + mysql_dml = mysql_converter.convert_statement(mysql_dml) + except Exception as e: + log.warning("SQL转换失败,使用原始SQL: {}", e) + mysql_dml = dml + + validate_safe_sql(mysql_dml, is_root=False) + engine = await MysqlHelper.get_user_engine(database_instance) + async with engine.connect() as conn: + result = await conn.execute(text(mysql_dml)) + await conn.commit() + return { + "rowcount": result.rowcount, + "lastrowid": result.lastrowid + } + + +async def deploy_mysql_ddl(db_name: str, statements: list[str]): + """ + 具体的 MySQL 部署物理实现。 + """ + try: + engine = await MysqlHelper.get_root_engine() + async with engine.connect() as conn: + # 1. 物理环境重置 + log.info("[MySQL-Physical] Resetting database: {}", db_name) + await conn.execute(text(f"DROP DATABASE IF EXISTS `{db_name}`;")) + await conn.execute(text(f"CREATE DATABASE `{db_name}`;")) + await conn.execute(text(f"USE `{db_name}`;")) + + # 2. 批量执行语句 + for stmt in statements: + stmt = stmt.strip() + if not stmt or any(x in stmt.upper() for x in ["CREATE DATABASE", "USE "]): + continue + + log.debug("[MySQL-Physical] Executing: {}...", stmt[:50]) + await conn.execute(text(stmt)) + + await conn.commit() + log.info("[MySQL-Physical] Deployment for {} completed.", db_name) + except Exception as e: + log.error("[MySQL-Physical] Critical Error: {}", e) + raise DatabaseOperationFailedException("MySQL physical execution failed: {}".format(str(e))) \ No newline at end of file diff --git a/src/backend/app/mysql/mysql_secure.py b/src/backend/app/mysql/mysql_secure.py new file mode 100644 index 0000000..2392136 --- /dev/null +++ b/src/backend/app/mysql/mysql_secure.py @@ -0,0 +1,80 @@ +""" +MySQL 安全模块。 + +提供 SQL 语句的安全校验、标准化及权限检查功能,防止 SQL 注入和越权操作。 +""" + +# backend/app/mysql/mysql_secure.py + +import re +from typing import List + +from core.exceptions import SQLSecurityException +from core.config import config + +def normalize_sql(sql: str) -> str: + """ + 标准化 SQL 语句。 + + 将 SQL 语句统一转换为大写,并去除多余的空格、换行符和注释, + 以便于后续的安全规则匹配。 + + Args: + sql (str): 原始 SQL 语句。 + + Returns: + str: 标准化后的 SQL 字符串。 + """ + # 去除注释(-- 单行注释) + sql = re.sub(r"--.*?$", "", sql, flags=re.MULTILINE) + # 去除换行/多个空格 → 单个空格 + sql = re.sub(r"\s+", " ", sql.strip()) + # 转大写 + return sql.upper() + +def match_operation(sql: str, operation_patterns: List[str]) -> bool: + """ + 匹配 SQL 是否包含指定操作模式(支持 * 通配符)。 + + Args: + sql (str): 标准化后的 SQL 字符串。 + operation_patterns (List[str]): 操作模式列表(如 ["CREATE DATABASE *", "DROP *"])。 + + Returns: + bool: 如果匹配到任意模式则返回 True,否则返回 False。 + """ + for pattern in operation_patterns: + # 把通配符 * 转为正则匹配(匹配任意字符) + regex_pattern = pattern.replace("*", ".*?").replace(" ", r"\s+") + if re.search(regex_pattern, sql, flags=re.IGNORECASE): + return True + return False + +def validate_safe_sql(sql: str, is_root: bool) -> None: + """ + 执行 SQL 安全权限检查。 + + 根据用户角色(Root 或普通用户)检查 SQL 语句是否包含被禁止的操作, + 或者是否在允许的操作列表中。 + + Args: + sql (str): 待检查的 SQL 语句。 + is_root (bool): 是否为 Root 用户权限。 + + Raises: + SQLSecurityException: 当操作被禁止或未在允许列表中时抛出。 + """ + normalized_sql = normalize_sql(sql) + + if is_root: + allowed = config.sql_permissions.root.allowed_operations + forbidden = config.sql_permissions.root.forbidden_operations + else: + allowed = config.sql_permissions.normal.allowed_operations + forbidden = config.sql_permissions.normal.forbidden_operations + + if match_operation(normalized_sql, forbidden): + raise SQLSecurityException("操作被禁止") + + if not match_operation(normalized_sql, allowed): + raise SQLSecurityException("操作不被允许") \ No newline at end of file diff --git a/src/backend/app/mysql/test.py b/src/backend/app/mysql/test.py new file mode 100644 index 0000000..c23811d --- /dev/null +++ b/src/backend/app/mysql/test.py @@ -0,0 +1,43 @@ +""" +MySQL 连接测试脚本。 + +用于验证 asyncio 和 aiomysql 在不同环境下的连接兼容性(仅供开发测试)。 +""" + +# backend/app/mysql/test.py + +# import asyncio +# import sys +# import aiomysql +# +# async def test_aiomysql_direct(): +# # 强制切换 Windows 事件循环 +# if sys.platform == 'win32': +# asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +# +# # 直接用 aiomysql 连接 +# conn = await aiomysql.connect( +# host='127.0.0.1', +# port=3306, +# user='root', +# password='root_secure_2025', +# db='mysql', +# auth_plugin='mysql_native_password', +# ssl=False # 明确禁用 SSL +# ) +# +# # 执行查询 +# async with conn.cursor() as cur: +# await cur.execute("SHOW GRANTS;") +# grants = await cur.fetchall() +# print("=== Root 用户权限 ===") +# for g in grants: +# print(g[0]) +# +# # 关闭连接 +# conn.close() +# await conn.wait_closed() +# print("\n=== 连接已关闭 ===") +# +# if __name__ == "__main__": +# asyncio.run(test_aiomysql_direct()) \ No newline at end of file diff --git a/src/backend/app/postgresql/__init__.py b/src/backend/app/postgresql/__init__.py new file mode 100644 index 0000000..d790bb0 --- /dev/null +++ b/src/backend/app/postgresql/__init__.py @@ -0,0 +1,7 @@ +""" +PostgreSQL 模块初始化。 + +提供 PostgreSQL 数据库连接、执行、转换和安全校验等核心功能。 +""" + +# backend/app/postgresql/__init__.py diff --git a/src/backend/app/postgresql/postgres_converter.py b/src/backend/app/postgresql/postgres_converter.py new file mode 100644 index 0000000..e0f23ba --- /dev/null +++ b/src/backend/app/postgresql/postgres_converter.py @@ -0,0 +1,533 @@ +""" +PostgreSQL 转换器。 + +专门用于将 SQLite 数据库模式和 SQL 语句深度转换为 PostgreSQL 兼容格式。 +""" + +# backend/app/postgresql/postgres_converter.py + +from typing import Dict, Any, List +from core.sql_dialect_converter import SQLDialectConverter, SQLConversionError + +import re +import sqlglot +from sqlglot import exp, parse_one +from typing import Dict, List, Optional, Tuple, Union, Callable +from collections import defaultdict + +# 动态导入表达式类型 (解决版本兼容问题) +try: + # sqlglot 24.0+ 使用新结构 + from sqlglot.expressions import ( + CreateTable as CreateTableExpr, + ColumnDef as ColumnDefExpr, + ForeignKey as ForeignKeyExpr, + PrimaryKey as PrimaryKeyExpr, + DataType as DataTypeExpr, + Properties as PropertiesExpr, + Property as PropertyExpr, + Literal as LiteralExpr, + Var as VarExpr, + OnDelete as OnDeleteExpr, + OnUpdate as OnUpdateExpr + ) +except ImportError: + # 旧版本回退 + CreateTableExpr = type('CreateTable', (), {'key': 'create_table'}) + ColumnDefExpr = type('ColumnDef', (), {'key': 'column_def'}) + ForeignKeyExpr = type('ForeignKey', (), {'key': 'foreign_key'}) + PrimaryKeyExpr = type('PrimaryKey', (), {'key': 'primary_key'}) + DataTypeExpr = exp.DataType + PropertiesExpr = exp.Properties + PropertyExpr = exp.Property + LiteralExpr = exp.Literal + VarExpr = exp.Var + OnDeleteExpr = exp.Delete + OnUpdateExpr = exp.Update + +class PostgreSQLConverter: + """ + SQLite到PostgreSQL深度转换器 (完全兼容 sqlglot 24.0+) + 专为模式转换优化,精准处理保留字/主键/精度/外键/字符集等关键问题 + """ + + # PostgreSQL保留字集合 (来自PostgreSQL 15+官方文档) + POSTGRESQL_RESERVED_WORDS = { + "user", "order", "group", "key", "index", "system", "option", "status", + "current_date", "current_time", "current_timestamp", "database", "schema", + "table", "view", "procedure", "function", "trigger", "event", "partition", + "session", "transaction", "commit", "rollback", "savepoint", "lock", "grant", + "revoke", "role", "type", "enum", "array", "json", "jsonb", "xml", "uuid" + } + + # 智能字段映射规则 (列名 -> 精度规则) + FIELD_PRECISION_RULES = { + # 价格/金额字段 + r"(?i)(price|amount|cost|fee|total|balance|discount)": ("DECIMAL", (10, 2)), + # 数量/计数字段 + r"(?i)(quantity|count|num|stock|capacity)": ("INTEGER", None), + # 折扣率/百分比字段 + r"(?i)(discount|rate|percentage|ratio|probability)": ("DECIMAL", (5, 4)), + # 时长/时间字段 + r"(?i)(duration|time_span|hours|minutes|seconds)": ("DECIMAL", (10, 2)), + # ID/标识符字段 + r"(?i)(id|uuid|guid|code|identifier|ref)": ("VARCHAR", 50), + # 电话/信用卡等特殊字段 + r"(?i)(phone|telephone|contact_number)": ("VARCHAR", 20), + r"(?i)(credit_card|card_number)": ("CHAR", 19), + # 通用文本字段 (主键/外键相关) + r"(?i)(name|type|category|status|preference)": ("VARCHAR", 100), + } + + # 默认VARCHAR长度 (主键/索引列) + DEFAULT_VARCHAR_LENGTH = 255 # PostgreSQL支持更长的索引长度 + + def __init__(self): + """ + 初始化转换器。 + + 设置基础 SQL 方言转换器、启用深度转换并初始化统计计数器。 + 同时建立 AST 节点类型与转换方法的映射关系。 + """ + self.generic_converter = SQLDialectConverter() + self.deep_conversion_enabled = True + self.conversion_stats = defaultdict(int) + + # 重构转换器映射 (使用节点key而非类类型) + self.ast_transformers = { + "create_table": self._transform_create_table, + "foreign_key": self._transform_foreign_key, + "primary_key": self._transform_primary_key, + "column_def": self._transform_column_def, + } + + def _apply_ast_transformations(self, ast: exp.Expression): + """ + 递归应用 AST 转换规则 (兼容 sqlglot 新旧版本)。 + + 遍历抽象语法树 (AST) 的每个节点,根据节点类型 (如 create_table, foreign_key) + 调用相应的转换方法 (`_transform_xxx`) 进行深度修改。 + + Args: + ast (exp.Expression): 待转换的 SQL 抽象语法树根节点。 + """ + # 遍历所有节点 + for node in ast.walk(): + # 获取节点类型标识 (兼容新旧版本) + node_key = getattr(node, "key", None) + if not node_key and hasattr(node, "__class__"): + node_key = node.__class__.__name__.lower() + + # 应用匹配的转换器 + transformer = self.ast_transformers.get(node_key) + if transformer: + try: + transformer(node) + except Exception as e: + print(f"转换节点 {node_key} 时出错: {str(e)}") + + # 全局表属性修复 (必须最后执行) + for node in ast.walk(): + node_key = getattr(node, "key", None) or node.__class__.__name__.lower() + if node_key == "create_table": + self._add_table_properties(node) + + def _transform_create_table(self, node): + """ + 转换 CREATE TABLE 语句。 + + 主要功能: + 1. 检查表名是否为 PostgreSQL 保留字(如 user, group, order 等)。 + 2. 如果是保留字,则使用双引号 (") 包裹表名,避免语法错误。 + + Args: + node: create_table 类型的 AST 节点。 + """ + # 获取表名 (兼容新旧结构) + table_name = None + if hasattr(node, "this") and hasattr(node.this, "name"): + table_name = node.this.name.lower() + elif hasattr(node, "expression") and hasattr(node.expression, "name"): + table_name = node.expression.name.lower() + + if table_name and table_name in self.POSTGRESQL_RESERVED_WORDS: + # 用双引号包裹表名 + new_name = f'"{node.this.name}"' if hasattr(node, "this") else f'"{node.expression.name}"' + if hasattr(node, "this"): + node.this.set("this", new_name) + else: + node.expression.set("this", new_name) + self.conversion_stats["reserved_word_fixes"] += 1 + + def _transform_primary_key(self, node): + """ + 修复主键定义。 + + SQLite 允许主键为 TEXT 类型,但在 PostgreSQL 中主键索引有长度限制。 + 此方法会查找 TEXT 类型的主键列,并将其类型强制转换为 VARCHAR(255)。 + + Args: + node: primary_key 类型的 AST 节点。 + """ + # 获取主键列表达式 + pk_columns = [] + if hasattr(node, "expressions"): + pk_columns = node.expressions + elif hasattr(node, "args") and "expressions" in node.args: + pk_columns = node.args["expressions"] + + # 检查主键列 + for col_expr in pk_columns: + col_name = None + if hasattr(col_expr, "name"): + col_name = col_expr.name + elif hasattr(col_expr, "this") and hasattr(col_expr.this, "name"): + col_name = col_expr.this.name + + if not col_name: + continue + + # 向上查找表定义 + table_node = node.find_ancestor(lambda n: getattr(n, "key", "") == "create_table") + if not table_node: + continue + + # 查找列定义 + for col_def in table_node.find_all(lambda n: getattr(n, "key", "") == "column_def"): + if hasattr(col_def, "this") and col_def.this.name == col_name: + # 检查类型 + col_type = None + if hasattr(col_def, "kind"): + col_type = col_def.kind + elif hasattr(col_def, "args") and "kind" in col_def.args: + col_type = col_def.args["kind"] + + if col_type and hasattr(col_type, "this") and col_type.this == "TEXT": + # 替换为 VARCHAR(255) + new_type = DataTypeExpr.build(f"VARCHAR({self.DEFAULT_VARCHAR_LENGTH})") + if hasattr(col_def, "kind"): + col_def.set("kind", new_type) + else: + col_def.args["kind"] = new_type + self.conversion_stats["text_primary_key_fixes"] += 1 + + def _transform_column_def(self, node): + """ + 智能修复列定义。 + + 包含两个主要修复逻辑: + 1. **TEXT 主键修复**:如果列定义中包含主键约束且类型为 TEXT,转为 VARCHAR(255)。 + 2. **智能精度分配**:针对 DECIMAL/NUMERIC 类型,根据列名模式(如 price, rate)自动匹配 + 合适的精度(如 DECIMAL(10,2)),避免 PostgreSQL 默认精度可能导致的精度丢失问题。 + + Args: + node: column_def 类型的 AST 节点。 + """ + col_name = node.this.name.lower() if hasattr(node, "this") else "" + + # 1. 修复TEXT主键 (列级主键) + if hasattr(node, "constraints"): + has_primary_key_constraint = False + for constraint in node.constraints: + if getattr(constraint, "kind", None) == "primary": + has_primary_key_constraint = True + col_type = getattr(node, "kind", None) + if col_type and getattr(col_type, "this", None) == "TEXT": + new_type = DataTypeExpr.build(f"VARCHAR({self.DEFAULT_VARCHAR_LENGTH})") + node.set("kind", new_type) + self.conversion_stats["text_primary_key_fixes"] += 1 + break # 修复一次就够了 + + # 如果有主键约束但类型不是TEXT,则不需要额外处理 + if has_primary_key_constraint: + return + + # 2. 智能精度分配 + col_type = getattr(node, "kind", None) + if col_type and getattr(col_type, "this", None) in ("NUMERIC", "DECIMAL"): + for pattern, (data_type, precision) in self.FIELD_PRECISION_RULES.items(): + if re.search(pattern, col_name): + # 应用精度规则 + if data_type == "INTEGER": + new_type = DataTypeExpr.Type.INT + elif data_type == "DECIMAL" and precision: + new_type = DataTypeExpr.build(f"DECIMAL({precision[0]},{precision[1]})") + elif data_type in ("VARCHAR", "CHAR") and precision: + new_type = DataTypeExpr.build(f"{data_type}({precision})") + else: + new_type = DataTypeExpr.build("DECIMAL(10,2)") + + node.set("kind", new_type) + self.conversion_stats[f"precision_fix_{col_name}"] += 1 + break + else: + # 默认精度规则 + node.set("kind", DataTypeExpr.build("DECIMAL(10,2)")) + self.conversion_stats["default_precision_fixes"] += 1 + + def _transform_foreign_key(self, node): + """ + 添加缺失的外键级联规则。 + + SQLite 往往省略外键行为,而 PostgreSQL 需要显式指定。 + 如果外键定义中缺少 ON DELETE 或 ON UPDATE 规则,此方法会默认添加 CASCADE 级联规则, + 确保数据完整性。 + + Args: + node: foreign_key 类型的 AST 节点。 + """ + # 检查是否已有ON DELETE/UPDATE + has_on_delete = False + has_on_update = False + + if hasattr(node, "expressions"): + for expr in node.expressions: + if isinstance(expr, OnDeleteExpr): + has_on_delete = True + elif isinstance(expr, OnUpdateExpr): + has_on_update = True + + # 添加缺失的级联规则 + if not has_on_delete: + on_delete = OnDeleteExpr(this=VarExpr(this="CASCADE")) + if hasattr(node, "append"): + node.append("expressions", on_delete) + else: + node.expressions.append(on_delete) + self.conversion_stats["fk_cascade_additions"] += 1 + + if not has_on_update: + on_update = OnUpdateExpr(this=VarExpr(this="CASCADE")) + if hasattr(node, "append"): + node.append("expressions", on_update) + else: + node.expressions.append(on_update) + self.conversion_stats["fk_cascade_additions"] += 1 + + def _add_table_properties(self, node): + """ + 添加表属性。 + + PostgreSQL 表属性相对简单,主要设置表空间和注释等。 + 这是建表语句转换的最后一步。 + + Args: + node: create_table 类型的 AST 节点。 + """ + # PostgreSQL 通常不需要强制指定引擎,但可以添加表空间等属性 + # 这里主要确保表名正确处理 + + # 检查是否需要添加表空间等属性 (可选) + # PostgreSQL 默认使用 pg_default 表空间 + + # 确保表名正确处理 (双引号) + if hasattr(node, "this") and hasattr(node.this, "name"): + table_name = node.this.name + if table_name.lower() in self.POSTGRESQL_RESERVED_WORDS: + node.this.set("this", f'"{table_name}"') + + self.conversion_stats["table_properties_added"] += 1 + + def convert_schema(self, sqlite_sql: str) -> str: + """ + 深度转换 SQLite 表结构定义到 PostgreSQL 格式。 + + 结合了通用方言转换(基于 sqlglot 默认规则)和自定义 AST 深度转换(`_apply_ast_transformations`), + 生成生产级可用的 PostgreSQL DDL 语句。 + + Args: + sqlite_sql (str): SQLite 格式的 DDL 语句。 + + Returns: + str: 转换后的 PostgreSQL 格式 DDL 语句。 + """ + try: + # 基础转换 + postgres_sql = self.generic_converter.convert( + sqlite_sql, 'sqlite', 'postgres', pretty=True + ) + + # AST深度转换 (仅当启用时) + if self.deep_conversion_enabled: + try: + # 关键修复: 使用正确的read方言 + ast = parse_one(postgres_sql, read="postgres", dialect="postgres") + self._apply_ast_transformations(ast) + postgres_sql = ast.sql(dialect="postgres", pretty=True) + except Exception as ast_err: + self.conversion_stats["ast_conversion_failures"] += 1 + print(f"AST转换失败,回退到基础转换: {str(ast_err)}") + + # 兜底修复 + postgres_sql = self._apply_fallback_fixes(postgres_sql) + return postgres_sql + except Exception as e: + raise SQLConversionError(f"SQLite到PostgreSQL模式转换失败: {str(e)}") + + def _apply_fallback_fixes(self, sql: str) -> str: + """字符串级兜底修复 (AST转换失败时使用)""" + # 1. 修复TEXT主键 (简单模式) + # 移除重复的PRIMARY KEY定义,只保留列级别的 + sql = re.sub( + r'(^\s*CREATE\s+TABLE\s+\w+\s*$$[^$$]*?),\s*PRIMARY\s+KEY\s*$$[^$$]*?$$', + r'\1', + sql, + flags=re.IGNORECASE | re.MULTILINE | re.DOTALL + ) + + # 将TEXT类型主键转换为VARCHAR并保留PRIMARY KEY约束 + sql = re.sub( + r'(\w+)\s+TEXT\s+NOT NULL', + lambda m: f'{m.group(1)} VARCHAR({self.DEFAULT_VARCHAR_LENGTH}) NOT NULL', + sql, + flags=re.IGNORECASE + ) + + # 2. 修复NUMERIC精度 (通用规则) + sql = re.sub( + r'(\w+)\s+NUMERIC\s*', + lambda m: f'{m.group(1)} DECIMAL(10,2)', + sql, + flags=re.IGNORECASE + ) + + # 3. 修复AUTOINCREMENT为SERIAL + sql = re.sub( + r'(\w+)\s+INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT', + r'\1 SERIAL PRIMARY KEY', + sql, + flags=re.IGNORECASE + ) + + # 4. 修复布尔值 + sql = re.sub( + r'(\w+)\s+BOOLEAN\s*', + r'\1 BOOLEAN', + sql, + flags=re.IGNORECASE + ) + + return sql + + def convert_statement(self, sqlite_sql: str) -> str: + """ + 转换SQLite SQL语句到PostgreSQL格式 + + Args: + sqlite_sql: SQLite的SQL语句 + + Returns: + PostgreSQL兼容的SQL语句 + """ + try: + # 基础转换 + postgres_sql = self.generic_converter.convert( + sqlite_sql, 'sqlite', 'postgres', pretty=False + ) + + # 仅对DDL语句进行深度转换 + if any(keyword in postgres_sql.upper() for keyword in ["CREATE TABLE", "ALTER TABLE"]): + return self.convert_schema(postgres_sql) # 复用schema转换 + + # DML/其他语句 - 应用轻量级修复 + return self._apply_statement_fixes(postgres_sql) + except Exception as e: + raise SQLConversionError(f"SQLite到PostgreSQL语句转换失败: {str(e)}") + + def _apply_statement_fixes(self, sql: str) -> str: + """轻量级语句修复 (DML/其他语句)""" + # 1. 修复布尔值 + sql = sql.replace('TRUE', 'TRUE').replace('FALSE', 'FALSE') # PostgreSQL 布尔值 + + # 2. 修复日期函数 + sql = re.sub( + r"strftime\('%Y-%m-%d %H:%M:%S',\s*(\w+)\)", + r"TO_CHAR(\1, 'YYYY-MM-DD HH24:MI:SS')", + sql, + flags=re.IGNORECASE + ) + + # 3. 修复AUTOINCREMENT + sql = sql.replace('AUTOINCREMENT', 'SERIAL') + + # 4. 修复LIMIT语法 + sql = re.sub( + r'LIMIT\s+(\d+)\s*OFFSET\s*(\d+)', + r'LIMIT \1 OFFSET \2', + sql, + flags=re.IGNORECASE + ) + + return sql + + def batch_convert(self, sqlite_statements: List[str]) -> List[str]: + """ + 批量转换SQLite语句到PostgreSQL格式 (带错误隔离) + + Args: + sqlite_statements: SQLite语句列表 + + Returns: + 转换结果列表 (失败语句包含错误注释) + """ + postgres_statements = [] + for i, statement in enumerate(sqlite_statements): + stmt = statement.strip() + if not stmt: + continue + + try: + # 智能判断语句类型 + if "CREATE TABLE" in stmt.upper() or "ALTER TABLE" in stmt.upper(): + converted = self.convert_schema(stmt) + else: + converted = self.convert_statement(stmt) + + postgres_statements.append(converted) + self.conversion_stats["successful_conversions"] += 1 + except SQLConversionError as e: + self.conversion_stats["conversion_errors"] += 1 + error_annotation = f"-- 转换失败 (语句#{i + 1}): {str(e)}\n" + postgres_statements.append(f"{error_annotation}{stmt}") + except Exception as e: + self.conversion_stats["unexpected_errors"] += 1 + error_annotation = f"-- 未处理错误 (语句#{i + 1}): {str(e)}\n" + postgres_statements.append(f"{error_annotation}{stmt}") + + return postgres_statements + + def get_conversion_stats(self) -> Dict[str, int]: + """获取转换统计信息""" + return dict(self.conversion_stats) + + def disable_deep_conversion(self): + """禁用深度AST转换 (回退到基础模式)""" + self.deep_conversion_enabled = False + + def enable_deep_conversion(self): + """启用深度AST转换""" + self.deep_conversion_enabled = True + + +# 使用示例 +if __name__ == "__main__": + # 示例:SQLite到PostgreSQL的深度转换 + converter = PostgreSQLConverter() + + # SQLite表结构示例 + sqlite_create_table = """CREATE TABLE Hotel (Name TEXT NOT NULL, Address TEXT, Phone TEXT, PRIMARY KEY(Name)); +CREATE TABLE Room (Type TEXT NOT NULL, Price NUMERIC, Quantity NUMERIC, PRIMARY KEY(Type)); +CREATE TABLE Booking (BookingID TEXT NOT NULL, RoomType TEXT NOT NULL, StartDate DATETIME, EndDate DATETIME, PRIMARY KEY(BookingID), FOREIGN KEY(RoomType) REFERENCES Room(Type)); +CREATE TABLE User (UserID TEXT NOT NULL, Name TEXT, Contact TEXT, Discount NUMERIC, CreditCard TEXT, PRIMARY KEY(UserID)); +CREATE TABLE Booking_Room (BookingID TEXT NOT NULL, RoomType TEXT NOT NULL, Duration NUMERIC, RoomPreference TEXT, PRIMARY KEY(BookingID, RoomType), FOREIGN KEY(BookingID) REFERENCES Booking(BookingID), FOREIGN KEY(RoomType) REFERENCES Room(Type)); +CREATE TABLE User_Booking (UserID TEXT NOT NULL, BookingID TEXT NOT NULL, PaymentMethod TEXT, LoyaltyProgram TEXT, PRIMARY KEY(UserID, BookingID), FOREIGN KEY(UserID) REFERENCES User(UserID), FOREIGN KEY(BookingID) REFERENCES Booking(BookingID));""" + + for sql in sqlite_create_table.split(';'): + try: + postgres_create_table = converter.convert_schema(sql) + print("SQLite:") + print(sql) + print("\n转换后的PostgreSQL:") + print(postgres_create_table) + except SQLConversionError as e: + print(f"转换失败: {e}") diff --git a/src/backend/app/postgresql/postgres_database.py b/src/backend/app/postgresql/postgres_database.py new file mode 100644 index 0000000..2e1f42a --- /dev/null +++ b/src/backend/app/postgresql/postgres_database.py @@ -0,0 +1,452 @@ +from typing import Optional, List +""" +PostgreSQL 数据库管理器。 + +处理 PostgreSQL 数据库连接、引擎初始化及资源释放,支持 Root 和普通用户连接池管理。 +""" + +# backend/app/postgresql/postgres_database.py + +import asyncio +from typing import Optional + +from sqlalchemy import text +from sqlalchemy.engine import url +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from config.base import PostgresConfig +from core.exceptions import InvalidOperationException +from core.log import log +from models import DatabaseInstance + + +class PostgresHelper: + """ + PostgreSQL 数据库连接处理类 (原生 SQL 执行模式)。 + + 设计原则: + 1. 超级用户 (postgres) 连接仅用于初始化 (创建用户和 DB)。 + 2. 业务 SQL 必须通过普通用户连接执行。 + 3. 严格隔离权限,防止 SQL 注入。 + """ + + _root_engine: Optional[AsyncEngine] = None + _user_engine: dict[int, AsyncEngine] = {} + _user_exist: set[str] = set() + # 用于防止并发授权导致的 "tuple concurrently updated" 错误 + _grant_locks: dict[str, asyncio.Lock] = {} + _grant_locks_lock = asyncio.Lock() + + @classmethod + async def _get_grant_lock(cls, key: str) -> asyncio.Lock: + """ + 获取指定 key 的授权锁,如果不存在则创建 + """ + async with cls._grant_locks_lock: + if key not in cls._grant_locks: + cls._grant_locks[key] = asyncio.Lock() + return cls._grant_locks[key] + + # 测试有没有连上PostgreSQL + @classmethod + async def test_connection(cls): + """ + 测试 Root 连接是否可用。 + + 执行简单的 `SELECT 1` 语句来验证数据库连通性。 + + Raises: + Exception: 连接失败时抛出异常。 + """ + engine = await cls.get_root_engine() + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + + @classmethod + async def init_root_engine(cls, postgres_config: PostgresConfig) -> None: + """ + 初始化 Root 用户引擎。 + + 建立一个具有最高权限的数据库连接池,用于执行 DDL 等管理操作。 + + Args: + postgres_config (PostgresConfig): 从 YAML 配置文件加载的 PostgreSQL 配置对象。 + """ + connect_args = {} + + cls._root_engine = create_async_engine( + postgres_config.sqlalchemy_database_url, + connect_args=connect_args, + pool_size=postgres_config.pool_size, + pool_recycle=postgres_config.pool_recycle, + pool_timeout=postgres_config.pool_timeout, + max_overflow=postgres_config.max_overflow + ) + + @classmethod + async def init_user_engine(cls, postgres_config: PostgresConfig, instance_id: int, user_database_url: url): + """ + 初始化普通用户引擎。 + + 为每个具体的数据库实例(Project)建立独立的连接池,使用受限权限的账号。 + + Args: + postgres_config (PostgresConfig): 基础配置。 + instance_id (int): 数据库实例 ID,作为连接池的 Key。 + user_database_url (url): 包含用户名密码的具体连接 URL。 + """ + connect_args = {} + + log.info(f"init user engine: {user_database_url}") + cls._user_engine[instance_id] = create_async_engine( + user_database_url, + connect_args=connect_args, + pool_size=postgres_config.pool_size, + pool_recycle=postgres_config.pool_recycle, + pool_timeout=postgres_config.pool_timeout, + max_overflow=postgres_config.max_overflow + ) + + @classmethod + async def close_root_engine(cls): + """ + 关闭 Root 引擎。 + + 释放 Root 连接池资源。 + """ + await cls._root_engine.dispose() + cls._root_engine = None + log.info("close root engine") + + @classmethod + async def close_user_engine(cls, database_instance: DatabaseInstance): + """ + 关闭指定的用户引擎。 + + 释放特定数据库实例的连接池资源。 + + Args: + database_instance (DatabaseInstance): 需要关闭的数据库实例对象。 + """ + await cls._user_engine[database_instance.instance_id].dispose() + del cls._user_engine[database_instance.instance_id] + log.info(f"close user engine: {database_instance.instance_id}") + + @classmethod + async def close_all_engine(cls): + """ + 关闭所有引擎 + :return: + """ + if cls._root_engine is not None: + await cls.close_root_engine() + + for engine in cls._user_engine.values(): + await engine.dispose() + + @classmethod + async def get_root_engine(cls) -> AsyncEngine: + """ + 获取root用户引擎 + :return: + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + + log.info("get root engine") + return cls._root_engine + + @classmethod + async def get_root_engine_by_db(cls, db_name: str) -> AsyncEngine: + """ + 获取连接到特定数据库的 root 用户引擎。 + 用于在特定数据库上执行需要 root 权限的操作(如 GRANT 语句)。 + + 注意:此方法创建的引擎是临时的,调用者需要在使用后自行 dispose。 + + Args: + db_name: 目标数据库名称 + + Returns: + AsyncEngine: 连接到指定数据库的异步引擎 + + Raises: + InvalidOperationException: 如果 root 引擎尚未初始化 + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + + from core.config import config + + # 基于现有配置构建连接到特定数据库的 URL + db_url = url.URL.create( + drivername=config.postgresql.driver, + username=config.postgresql.username, + password=config.postgresql.password, + host=config.postgresql.host, + port=config.postgresql.port, + database=db_name + ) + + log.info(f"Creating root engine for database: {db_name}") + + # 创建临时引擎连接到指定数据库 + engine = create_async_engine( + db_url, + pool_size=1, # 临时引擎使用较小的连接池 + max_overflow=0, + pool_timeout=config.postgresql.pool_timeout, + pool_recycle=config.postgresql.pool_recycle + ) + + return engine + + @classmethod + async def get_user_engine(cls, database_instance: DatabaseInstance) -> AsyncEngine: + """ + 获取用户引擎 + :param database_instance: + :return: + """ + if database_instance.instance_id not in cls._user_engine: + raise InvalidOperationException("请先初始化用户引擎") + + log.info(f"get user engine: {database_instance.instance_id}") + return cls._user_engine[database_instance.instance_id] + + @classmethod + def is_user_engine_exists(cls, instance_id: int) -> bool: + """ + 判断用户引擎是否存在 + :param instance_id: + :return: + """ + return instance_id in cls._user_engine + + @classmethod + def is_user_exists(cls, db_username: str) -> bool: + """ + 检查_user_exist中是否存在指定用户 + :param db_username: 数据库用户名 + :return: 如果用户存在返回True,否则返回False + """ + + return db_username in cls._user_exist + + @classmethod + async def is_user_exist_in_postgres(cls, db_username: str) -> bool: + """ + 检查数据库中是否存在指定用户 + :param db_username: 待检查的用户名 + :return: 如果用户存在返回True,否则返回False + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + + try: + engine = await cls.get_root_engine() + async with engine.connect() as conn: + # 查询 pg_roles 视图,检查是否存在该用户 + result = await conn.execute( + text("SELECT rolname FROM pg_roles WHERE rolname = :username"), + {"username": db_username} + ) + return result.fetchone() is not None + except Exception as e: + log.error(f"检查用户是否存在失败: {e}") + return False + + @classmethod + def add_user(cls, db_username: str): + """ + 添加用户 + :param db_username: 待添加的用户名 + :return: + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + cls._user_exist.add(db_username) + + @classmethod + async def check_privilege(cls, db_username: str, db_name: str) -> bool: + """ + 检查用户对数据库的读写权限 + :param db_username: 数据库用户名 + :param db_name: 数据库名称 + :return: 如果用户对数据库有读写权限(SELECT/INSERT/UPDATE/DELETE)返回True,否则返回False + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + + # 定义需要的读写权限列表 + required_privileges = {"SELECT", "INSERT", "UPDATE", "DELETE"} + + try: + engine = await cls.get_root_engine() + async with engine.connect() as conn: + # 更准确地查询用户对指定数据库的权限 + result = await conn.execute( + text(""" + SELECT DISTINCT privilege_type + FROM information_schema.table_privileges + WHERE grantee = :username + AND table_catalog = :db_name + AND privilege_type = ANY(:privileges) + """), + { + "username": db_username, + "db_name": db_name, + "privileges": list(required_privileges) + } + ) + # 提取用户拥有的权限 + user_privileges = {row.privilege_type for row in result.fetchall()} + # 判断是否包含所有必需的读写权限 + return required_privileges.issubset(user_privileges) + except Exception as e: + log.error(f"检查用户[{db_username}]对数据库[{db_name}]的权限时出错: {e}", exc_info=True) + return False + + @classmethod + async def grant_user_privileges(cls, db_name: str, db_username: str): + """ + 为用户授予数据库的读写权限(修复:切换到目标库+补充默认权限) + 使用锁防止并发授权导致的 "tuple concurrently updated" 错误 + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + + # 使用 db_name + db_username 作为锁的 key,防止并发授权 + lock_key = f"{db_name}:{db_username}" + grant_lock = await cls._get_grant_lock(lock_key) + + async with grant_lock: + try: + # 1. 获取root引擎 + root_engine = await cls.get_root_engine() + async with root_engine.begin() as conn: + # 步骤1:授予数据库连接权限 + await conn.execute( + text(f"GRANT CONNECT ON DATABASE {db_name} TO {db_username}") + ) + + # 2. 连接到目标数据库授予权限 + # 构建目标数据库的连接URL + root_db_url = cls._root_engine.url + target_db_url = root_db_url.set(database=db_name) + target_engine = create_async_engine(target_db_url) + + async with target_engine.begin() as target_conn: + # 授予public schema使用权限 + await target_conn.execute( + text(f"GRANT USAGE ON SCHEMA public TO {db_username}") + ) + + # 授予现有表的读写权限(INSERT/SELECT/UPDATE/DELETE) + await target_conn.execute( + text(f"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {db_username}") + ) + + # 授予现有序列权限 + await target_conn.execute( + text(f"GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO {db_username}") + ) + + # 设置默认权限(未来创建的表/序列也有权限) + await target_conn.execute( + text(f"ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {db_username}") + ) + await target_conn.execute( + text(f"ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO {db_username}") + ) + + await target_engine.dispose() + + log.info(f"[PostgreSQL] 成功为用户 '{db_username}' 授予数据库 '{db_name}' 的读写权限") + except Exception as e: + raise RuntimeError( + f"为用户 '{db_username}' 授予数据库 '{db_name}' 权限失败: {str(e)}" + ) from e + + @classmethod + async def revoke_user_all_privileges(cls, db_name: str, db_username: str): + """ + 清理用户的所有权限依赖(删除用户前必须执行) + :param db_name: 目标数据库名 + :param db_username: 目标用户名 + """ + if cls._root_engine is None: + raise InvalidOperationException("请先初始化root用户引擎") + + try: + engine = await cls.get_root_engine() + async with engine.begin() as conn: + # 1. 终止活跃连接 + await conn.execute( + text(""" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE usename = :db_user AND pid <> pg_backend_pid(); + """), + {"db_user": db_username} + ) + + # 2. 撤销数据库级权限 + await conn.execute( + text(f"REVOKE ALL PRIVILEGES ON DATABASE {db_name} FROM {db_username};") + ) + + # 3. 切换到目标数据库,撤销schema/表/序列权限 + # 注:SQLAlchemy不支持\c,改用连接目标数据库执行 + # 临时创建目标数据库的连接(root用户) + root_db_url = cls._root_engine.url + temp_engine = create_async_engine( + f"{root_db_url.drivername}://{root_db_url.username}:{root_db_url.password}@{root_db_url.host}:{root_db_url.port}/{db_name}", + echo=False + ) + async with temp_engine.begin() as temp_conn: + # 撤销schema权限 + await temp_conn.execute( + text(f"REVOKE ALL PRIVILEGES ON SCHEMA public FROM {db_username};") + ) + # 撤销表/序列权限 + await temp_conn.execute( + text(f"REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM {db_username};") + ) + await temp_conn.execute( + text(f"REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM {db_username};") + ) + # 清理默认权限 + await temp_conn.execute( + text(f"ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM {db_username};") + ) + await temp_engine.dispose() + + # 4. 转移所有权+撤销剩余权限 + await conn.execute( + text(f"REASSIGN OWNED BY {db_username} TO postgres;") + ) + await conn.execute( + text(f"DROP OWNED BY {db_username};") + ) + + log.info(f"[PostgreSQL] 已清理用户 {db_username} 在数据库 {db_name} 的所有权限依赖") + except Exception as e: + raise RuntimeError(f"清理用户权限失败:{str(e)}") from e + + # 新增删除用户的函数(先清理权限,再删除) + @classmethod + async def drop_postgresql_user(cls, db_name: str, db_username: str): + """删除PostgreSQL用户(自动清理权限依赖)""" + # 先清理权限 + await cls.revoke_user_all_privileges(db_name, db_username) + # 再删除用户 + try: + engine = await cls.get_root_engine() + async with engine.begin() as conn: + await conn.execute( + text(f"DROP ROLE IF EXISTS {db_username};") + ) + log.info(f"[PostgreSQL] 用户 {db_username} 已删除") + except Exception as e: + raise RuntimeError(f"删除用户失败:{str(e)}") from e \ No newline at end of file diff --git a/src/backend/app/postgresql/postgres_execute.py b/src/backend/app/postgresql/postgres_execute.py new file mode 100644 index 0000000..784f892 --- /dev/null +++ b/src/backend/app/postgresql/postgres_execute.py @@ -0,0 +1,211 @@ +""" +PostgreSQL 执行器。 + +提供 Root 和普通用户的 SQL 执行接口,包含 DDL、DML 和 DQL 操作,并集成安全校验。 +""" + +# backend/app/postgresql/postgres_execute.py + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from core.log import log +from models import DatabaseInstance +from postgresql.postgres_database import PostgresHelper +from postgresql.postgres_secure import validate_safe_sql +from core.sql_dialect_converter import SQLDialectConverter +from postgresql.postgres_converter import PostgreSQLConverter +from core.exceptions import DatabaseOperationFailedException + +# 初始化转换器 +generic_converter = SQLDialectConverter() +postgres_converter = PostgreSQLConverter() + + +async def execute_sql_root(sql: str): + """ + Root 用户执行 SQL (DDL)。 + + 使用 Root 权限执行 SQL 语句,主要用于创建数据库、创建用户等管理操作。 + 执行前会进行 SQL 转换 (SQLite -> PostgreSQL) 和安全检查。 + + Args: + sql (str): 待执行的 SQL 语句 (可能是 SQLite 格式)。 + + Raises: + SQLSecurityException: 如果 SQL 包含被禁止的操作。 + """ + # 将SQL转换为PostgreSQL方言 + try: + postgres_sql = generic_converter.convert(sql, "sqlite", "postgres") + postgres_sql = postgres_converter.convert_statement(postgres_sql) + except Exception as e: + log.warning(f"SQL转换失败,使用原始SQL: {e}") + postgres_sql = sql + + validate_safe_sql(postgres_sql, is_root=True) + engine = await PostgresHelper.get_root_engine() + async with engine.connect() as conn: + await conn.execute(text(postgres_sql)) + log.info(f"root: successfully execute {postgres_sql}") + +async def execute_dql_root(dql: str): + """ + Root 用户执行 DQL (查询)。 + + 使用 Root 权限执行查询语句,返回字典列表格式的结果。 + + Args: + dql (str): 待执行的查询语句。 + + Returns: + list[dict]: 查询结果列表,每项为一个字典。 + """ + # 将SQL转换为PostgreSQL方言 + postgres_dql = None + try: + postgres_dql = generic_converter.convert(dql, "sqlite", "postgres") + postgres_dql = postgres_converter.convert_statement(postgres_dql) + except Exception as e: + log.warning(f"SQL转换失败,使用原始SQL: {e}") + + engine = await PostgresHelper.get_root_engine() + async with engine.connect() as conn: + result = await conn.execute(text(postgres_dql)) + query_result = result.mappings().fetchall() + return [dict(row) for row in query_result] + +async def execute_dql_user(dql: str, database_instance: DatabaseInstance): + """ + 普通用户执行 DQL (查询)。 + + 使用指定数据库实例的普通用户权限执行查询。会自动转换 SQL 方言并进行安全检查。 + + Args: + dql (str): 待执行的查询语句。 + database_instance (DatabaseInstance): 目标数据库实例对象。 + + Returns: + list[dict]: 查询结果列表。 + """ + # 将SQL转换为PostgreSQL方言 + try: + postgres_dql = generic_converter.convert(dql, "sqlite", "postgres") + postgres_dql = postgres_converter.convert_statement(postgres_dql) + except Exception as e: + log.warning(f"SQL转换失败,使用原始SQL: {e}") + postgres_dql = dql + + validate_safe_sql(postgres_dql, is_root=False) + engine = await PostgresHelper.get_user_engine(database_instance) + async with engine.connect() as conn: + result = await conn.execute(text(postgres_dql)) + query_result = result.mappings().fetchall() + return [dict(row) for row in query_result] + + +async def execute_dml_user(dml: str, database_instance: DatabaseInstance): + """ + 普通用户执行 DML (增删改)。 + + 使用指定数据库实例的普通用户权限执行数据变更操作。 + 执行后会自动提交事务。 + + Args: + dml (str): 待执行的 DML 语句 (INSERT/UPDATE/DELETE)。 + database_instance (DatabaseInstance): 目标数据库实例对象。 + + Returns: + dict: 包含受影响行数 (rowcount) 和最后插入ID (lastrowid) 的字典。 + """ + # 将SQL转换为PostgreSQL方言 + try: + postgres_dml = generic_converter.convert(dml, "sqlite", "postgres") + postgres_dml = postgres_converter.convert_statement(postgres_dml) + except Exception as e: + log.warning(f"SQL转换失败,使用原始SQL: {e}") + postgres_dml = dml + + validate_safe_sql(postgres_dml, is_root=False) + engine = await PostgresHelper.get_user_engine(database_instance) + async with engine.connect() as conn: + result = await conn.execute(text(postgres_dml)) + await conn.commit() + # PostgreSQL中没有lastrowid属性,所以设置为None + return { + "rowcount": result.rowcount, + "lastrowid": None + } + + +async def deploy_postgres_ddl(db_name: str, statements: list[str]): + """ + 具体的 PostgreSQL 部署物理实现。 + + 注意:CREATE DATABASE 不能在事务中执行,需要单独处理 + """ + try: + engine = await PostgresHelper.get_root_engine() + + # 1. 物理环境重置 - 需要在独立的连接中执行 + log.info(f"[PostgreSQL-Physical] Resetting database: {db_name}") + + # 创建临时连接用于数据库操作(不依赖原连接状态) + temp_engine = create_async_engine( + engine.url, + isolation_level="AUTOCOMMIT", # 关键:自动提交模式,允许DDL操作 + pool_pre_ping=True, # 连接前检查 + pool_recycle=3600 # 连接回收时间 + ) + + async with temp_engine.connect() as conn: + # [关键修复] 强制断开该数据库的所有现有连接 + # 否则如果有其他连接(如pgAdmin, 之前的连接池等)在使用该库,DROP会报错 ObjectInUseError + terminate_sql = f""" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '{db_name}' + AND pid <> pg_backend_pid(); + """ + # 删除数据库(如果存在) + await conn.execute(text(f'DROP DATABASE IF EXISTS "{db_name}";')) + + # 创建新数据库 + await conn.execute(text(f'CREATE DATABASE "{db_name}";')) + + await temp_engine.dispose() + + # 2. 连接到新创建的数据库并执行DDL语句 + # 构建新数据库的连接URL + new_url = engine.url.set(database=db_name) + db_engine = create_async_engine( + new_url, + pool_size=1, # 使用小连接池 + max_overflow=0 + ) + + async with db_engine.connect() as conn: + # 首先设置默认权限,确保后续创建的表都有正确的权限 + await conn.execute(text("ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO postgres")) + await conn.execute(text("ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO postgres")) + + for stmt in statements: + stmt = stmt.strip() + if not stmt: + continue + + # 跳过数据库创建语句(已经处理过了) + if stmt.upper().startswith("CREATE DATABASE"): + continue + + log.debug(f"[PostgreSQL-Physical] Executing: {stmt[:50]}...") + await conn.execute(text(stmt)) + + await conn.commit() + + await db_engine.dispose() + log.info(f"[PostgreSQL-Physical] Deployment for {db_name} completed.") + + except Exception as e: + log.error(f"[PostgreSQL-Physical] Critical Error: {e}") + raise DatabaseOperationFailedException(f"PostgreSQL physical execution failed: {str(e)}") diff --git a/src/backend/app/postgresql/postgres_secure.py b/src/backend/app/postgresql/postgres_secure.py new file mode 100644 index 0000000..ef58c84 --- /dev/null +++ b/src/backend/app/postgresql/postgres_secure.py @@ -0,0 +1,80 @@ +""" +PostgreSQL 安全模块。 + +提供 SQL 语句的安全校验、标准化及权限检查功能,防止 SQL 注入和越权操作。 +""" + +# backend/app/postgresql/postgres_secure.py + +import re +from typing import List + +from core.exceptions import SQLSecurityException +from core.config import config + +def normalize_sql(sql: str) -> str: + """ + 标准化 SQL 语句。 + + 将 SQL 语句统一转换为大写,并去除多余的空格、换行符和注释, + 以便于后续的安全规则匹配。 + + Args: + sql (str): 原始 SQL 语句。 + + Returns: + str: 标准化后的 SQL 字符串。 + """ + # 去除注释(-- 单行注释) + sql = re.sub(r"--.*?$", "", sql, flags=re.MULTILINE) + # 去除换行/多个空格 → 单个空格 + sql = re.sub(r"\s+", " ", sql.strip()) + # 转大写 + return sql.upper() + +def match_operation(sql: str, operation_patterns: List[str]) -> bool: + """ + 匹配 SQL 是否包含指定操作模式(支持 * 通配符)。 + + Args: + sql (str): 标准化后的 SQL 字符串。 + operation_patterns (List[str]): 操作模式列表(如 ["CREATE DATABASE *", "DROP *"])。 + + Returns: + bool: 如果匹配到任意模式则返回 True,否则返回 False。 + """ + for pattern in operation_patterns: + # 把通配符 * 转为正则匹配(匹配任意字符) + regex_pattern = pattern.replace("*", ".*?").replace(" ", r"\s+") + if re.search(regex_pattern, sql, flags=re.IGNORECASE): + return True + return False + +def validate_safe_sql(sql: str, is_root: bool) -> None: + """ + 执行 SQL 安全权限检查。 + + 根据用户角色(Root 或普通用户)检查 SQL 语句是否包含被禁止的操作, + 或者是否在允许的操作列表中。 + + Args: + sql (str): 待检查的 SQL 语句。 + is_root (bool): 是否为 Root 用户权限。 + + Raises: + SQLSecurityException: 当操作被禁止或未在允许列表中时抛出。 + """ + normalized_sql = normalize_sql(sql) + + if is_root: + allowed = config.sql_permissions.root.allowed_operations + forbidden = config.sql_permissions.root.forbidden_operations + else: + allowed = config.sql_permissions.normal.allowed_operations + forbidden = config.sql_permissions.normal.forbidden_operations + + if match_operation(normalized_sql, forbidden): + raise SQLSecurityException("操作被禁止") + + if not match_operation(normalized_sql, allowed): + raise SQLSecurityException("操作不被允许") diff --git a/src/backend/app/postgresql/test.py b/src/backend/app/postgresql/test.py new file mode 100644 index 0000000..8b1242b --- /dev/null +++ b/src/backend/app/postgresql/test.py @@ -0,0 +1,262 @@ +""" +PostgreSQL 连接测试脚本。 + +用于验证 asyncio 和 asyncpg 在不同环境下的连接兼容性(仅供开发测试)。 +""" + +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..')) + +from config.base import PostgresConfig +from postgresql.postgres_database import PostgresHelper +from postgresql.postgres_execute import deploy_postgres_ddl, execute_dql_user, execute_dml_user +from core.config import config +from service.postgresql_service import create_postgresql_user, grant_user_privileges, ensure_user_and_engine +from models.database_instance import DatabaseInstance + + +async def ddl_execution(): + """测试DDL执行功能""" + try: + # 使用配置文件中的PostgreSQL配置 + postgres_config = config.postgresql + + # 初始化root引擎 + print("初始化PostgreSQL root引擎...") + await PostgresHelper.init_root_engine(postgres_config) + print("PostgreSQL root引擎初始化成功!") + + # 测试用的DDL语句 + test_statements = [ + "CREATE TABLE student (student_id VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, age INT NOT NULL CHECK (age > 0), PRIMARY KEY (student_id))", + "CREATE TABLE course (course_number VARCHAR(255) UNIQUE NOT NULL, course_name VARCHAR(255) NOT NULL, credits INT NOT NULL CHECK (credits > 0), lecturer VARCHAR(255) NOT NULL, class_time VARCHAR(255) NOT NULL, PRIMARY KEY (course_number))", + "CREATE TABLE student_courses (student_id VARCHAR(255) NOT NULL, course_number VARCHAR(255) NOT NULL, PRIMARY KEY (student_id, course_number), FOREIGN KEY (student_id) REFERENCES student(student_id), FOREIGN KEY (course_number) REFERENCES course(course_number))" + ] + + print("开始测试PostgreSQL DDL执行...") + await deploy_postgres_ddl("test_database", test_statements) + print("DDL执行测试成功完成!") + + except Exception as e: + print(f"DDL执行测试失败: {e}") + import traceback + traceback.print_exc() + finally: + # 清理资源 + try: + await PostgresHelper.close_root_engine() + print("已清理PostgreSQL连接资源") + except: + pass + + +async def service_test(): + """测试PostgreSQL服务功能""" + try: + # 使用配置文件中的PostgreSQL配置 + postgres_config = config.postgresql + + # 初始化root引擎 + print("初始化PostgreSQL root引擎...") + await PostgresHelper.init_root_engine(postgres_config) + print("PostgreSQL root引擎初始化成功!") + + # 测试创建用户 + test_username = "test_user" + test_password = "test_password" + test_db_name = "test_database" + + print(f"开始测试创建用户 {test_username}...") + await create_postgresql_user(test_username, test_password) + print(f"用户 {test_username} 创建成功!") + + # 测试授予权限 + print(f"开始测试为用户 {test_username} 授予数据库 {test_db_name} 的权限...") + await grant_user_privileges(test_db_name, test_username) + print(f"为用户 {test_username} 授予数据库 {test_db_name} 的权限成功!") + + # 测试确保用户和引擎 + print("开始测试ensure_user_and_engine功能...") + # 创建一个模拟的DatabaseInstance对象 + instance = DatabaseInstance() + instance.db_name = test_db_name + instance.db_username = test_username + instance.db_password = test_password + instance.instance_id = 1 + + result = await ensure_user_and_engine(test_db_name, test_username, test_password, 1) + print(f"ensure_user_and_engine测试结果: {result}") + print("ensure_user_and_engine功能测试完成!") + + except Exception as e: + print(f"Service功能测试失败: {e}") + import traceback + traceback.print_exc() + finally: + # 清理资源 + try: + await PostgresHelper.close_root_engine() + print("已清理PostgreSQL连接资源") + except: + pass + + +async def data_test(): + """测试数据插入和查询功能""" + try: + # 使用配置文件中的PostgreSQL配置 + postgres_config = config.postgresql + + # 初始化root引擎 + print("初始化PostgreSQL root引擎...") + await PostgresHelper.init_root_engine(postgres_config) + print("PostgreSQL root引擎初始化成功!") + + # 创建DatabaseInstance对象用于测试 + instance = DatabaseInstance() + instance.db_name = "test_database" + instance.db_username = "test_user" + instance.db_password = "test_password" + instance.instance_id = 10086 + + # 确保用户和引擎已准备就绪 + await ensure_user_and_engine(instance.db_name, instance.db_username, instance.db_password, instance.instance_id) + + # 再次确保权限正确 + await PostgresHelper.grant_user_privileges(instance.db_name, instance.db_username) + + # 插入测试数据 + print("开始插入测试数据...") + + # 插入学生数据 + insert_student_sql = """ + INSERT INTO student (student_id, name, age) + VALUES + ('S001', '张三', 20), + ('S002', '李四', 21), + ('S003', '王五', 19) + """ + await execute_dml_user(insert_student_sql, instance) + print("学生数据插入完成!") + + # 插入课程数据 + insert_course_sql = """ + INSERT INTO course (course_number, course_name, credits, lecturer, class_time) + VALUES + ('C001', '数学', 4, '张教授', '周一 9:00-11:00'), + ('C002', '物理', 3, '李教授', '周二 14:00-16:00'), + ('C003', '化学', 3, '王教授', '周三 10:00-12:00') + """ + await execute_dml_user(insert_course_sql, instance) + print("课程数据插入完成!") + + # 插入选课数据 + insert_enrollment_sql = """ + INSERT INTO student_courses (student_id, course_number) + VALUES + ('S001', 'C001'), + ('S001', 'C002'), + ('S002', 'C001'), + ('S003', 'C003') + """ + await execute_dml_user(insert_enrollment_sql, instance) + print("选课数据插入完成!") + + # 测试查询功能 + print("开始测试查询功能...") + + # 查询所有学生 + select_students_sql = "SELECT * FROM student" + students_result = await execute_dql_user(select_students_sql, instance) + print("所有学生数据:") + for student in students_result: + print(f" 学号: {student['student_id']}, 姓名: {student['name']}, 年龄: {student['age']}") + + # 查询所有课程 + select_courses_sql = "SELECT * FROM course" + courses_result = await execute_dql_user(select_courses_sql, instance) + print("所有课程数据:") + for course in courses_result: + print(f" 课程号: {course['course_number']}, 课程名: {course['course_name']}, 学分: {course['credits']}, 教师: {course['lecturer']}, 时间: {course['class_time']}") + + # 查询选课情况 + select_enrollments_sql = "SELECT * FROM student_courses" + enrollments_result = await execute_dql_user(select_enrollments_sql, instance) + print("选课数据:") + for enrollment in enrollments_result: + print(f" 学号: {enrollment['student_id']}, 课程号: {enrollment['course_number']}") + + # 复杂查询:查询学生及其选课信息 + complex_query_sql = """ + SELECT s.name AS student_name, c.course_name, c.credits, c.lecturer + FROM student s + JOIN student_courses sc ON s.student_id = sc.student_id + JOIN course c ON sc.course_number = c.course_number + ORDER BY s.name, c.course_name + """ + complex_result = await execute_dql_user(complex_query_sql, instance) + print("学生选课详情:") + for record in complex_result: + print(f" 学生: {record['student_name']}, 课程: {record['course_name']}, 学分: {record['credits']}, 教师: {record['lecturer']}") + + print("数据查询测试完成!") + + except Exception as e: + print(f"数据测试失败: {e}") + import traceback + traceback.print_exc() + finally: + # 清理资源 + try: + await PostgresHelper.close_root_engine() + print("已清理PostgreSQL连接资源") + except: + pass + + +async def main(): + """主测试函数""" + print("=" * 50) + print("开始PostgreSQL DDL执行测试") + print("=" * 50) + await ddl_execution() + + print("\n" + "=" * 50) + print("开始PostgreSQL Service功能测试") + print("=" * 50) + await service_test() + + print("\n" + "=" * 50) + print("开始PostgreSQL 数据操作测试") + print("=" * 50) + await data_test() + + +async def clean_user(): + postgres_config = config.postgresql + # 初始化root引擎 + print("初始化PostgreSQL root引擎...") + await PostgresHelper.init_root_engine(postgres_config) + print("PostgreSQL root引擎初始化成功!") + + # 先尝试删除用户(忽略错误) + try: + await PostgresHelper.drop_postgresql_user("test_database", "test_user") + except Exception as e: + print(f"清理用户时出错(可忽略): {e}") + + # 关闭引擎 + await PostgresHelper.close_root_engine() + + +if __name__ == "__main__": + # 先清理旧的用户和数据库 + asyncio.run(clean_user()) + + # 然后运行主测试 + asyncio.run(main()) + # asyncio.run(clean_user()) diff --git a/src/backend/app/redis_client/__init__.py b/src/backend/app/redis_client/__init__.py new file mode 100644 index 0000000..b39f31d --- /dev/null +++ b/src/backend/app/redis_client/__init__.py @@ -0,0 +1,7 @@ +""" +Redis 模块初始化。 + +提供 Redis 连接管理、客户端实例获取以及过期事件监听功能。 +""" + +# backend/app/redis_client/__init__.py diff --git a/src/backend/app/redis_client/cache_service.py b/src/backend/app/redis_client/cache_service.py new file mode 100644 index 0000000..2183823 --- /dev/null +++ b/src/backend/app/redis_client/cache_service.py @@ -0,0 +1,429 @@ +""" +统一缓存服务接口 + +提供标准化的缓存操作接口,支持各种类型的缓存数据操作 +实现了缓存穿透、雪崩、击穿的防护机制 +""" + +from typing import Any, Callable, Optional, TypeVar, Generic +import asyncio +import hashlib +import json +from datetime import timedelta + +from core.config import config +from core.log import log +from redis_client.redis import get_redis +from redis_client.redis_keys import redis_key_manager + +T = TypeVar('T') + +class CacheResult(Generic[T]): + """ + 缓存结果封装类 + """ + def __init__(self, data: Optional[T] = None, from_cache: bool = False, ttl: Optional[int] = None): + self.data = data + self.from_cache = from_cache + self.ttl = ttl + +class CacheService: + """ + 统一缓存服务类 + """ + + # 默认缓存时间 (秒) + DEFAULT_TTL = 300 # 5分钟 + # 长缓存时间 (秒) + LONG_TTL = 3600 # 1小时 + # 短缓存时间 (秒) + SHORT_TTL = 60 # 1分钟 + # 缓存穿透防护时间 (秒) + PENETRATION_TTL = 30 # 30秒 + # 分布式锁默认超时时间 (秒) + LOCK_DEFAULT_TTL = 10 + + @classmethod + async def get(cls, key: str, default: Any = None) -> Optional[Any]: + """ + 获取缓存数据 + + Args: + key: 缓存键 + default: 默认值 + + Returns: + Any: 缓存数据或默认值 + """ + redis = get_redis() + if not redis: + log.warning("Redis连接未初始化,尝试初始化连接...") + from redis_client.redis import init_redis + try: + await init_redis() + redis = get_redis() + except Exception as e: + log.error(f"Redis初始化失败: {e}") + return default + if not redis: + return default + + try: + value = await redis.get(key) + if value is None: + return default + + # 尝试反序列化为JSON对象 + try: + # 只有当值是字符串且看起来像JSON时才尝试反序列化 + if isinstance(value, bytes): + value = value.decode('utf-8') + if isinstance(value, str) and (value.startswith('{') or value.startswith('[')): + return json.loads(value) + return value + except json.JSONDecodeError: + # 不是JSON,返回原始值 + return value + except Exception as e: + log.error(f"获取缓存失败: {e}", exc_info=True) + return default + + @classmethod + async def set(cls, key: str, value: Any, ttl: Optional[int] = None) -> bool: + """ + 设置缓存数据 + + Args: + key: 缓存键 + value: 缓存值 + ttl: 过期时间(秒), 默认使用DEFAULT_TTL + + Returns: + bool: 是否设置成功 + """ + redis = get_redis() + if not redis: + return False + + try: + ttl = ttl or cls.DEFAULT_TTL + + # 序列化复杂对象为JSON + if value is not None and not isinstance(value, (str, int, float, bool)): + # 检查是否有model_dump方法(Pydantic模型) + if hasattr(value, 'model_dump'): + value = value.model_dump() + # 检查是否有dict方法 + elif hasattr(value, 'dict'): + value = value.dict() + value = json.dumps(value, default=str) # default=str 处理datetime等特殊类型 + + await redis.set(key, value, ex=ttl) + return True + except Exception as e: + log.error(f"设置缓存失败: {e}", exc_info=True) + return False + + @classmethod + async def delete(cls, key: str) -> bool: + """ + 删除缓存 + + Args: + key: 缓存键 + + Returns: + bool: 是否删除成功 + """ + log.info(f"[CacheService] Attempting to delete cache key: {key}") + redis = get_redis() + if not redis: + log.warning("Redis连接未初始化,尝试初始化连接...") + from redis_client.redis import init_redis + try: + await init_redis() + redis = get_redis() + except Exception as e: + log.error(f"Redis初始化失败: {e}") + return False + if not redis: + return False + + try: + result = await redis.delete(key) + log.info(f"[CacheService] Cache key '{key}' deletion result: {result}") + return result > 0 # 只有当实际删除了键时才返回True + except Exception as e: + log.error(f"删除缓存失败: {e}", exc_info=True) + return False + + @classmethod + async def delete_pattern(cls, pattern: str) -> int: + """ + 根据模式删除缓存 + + Args: + pattern: 缓存键模式 + + Returns: + int: 删除的缓存数量 + """ + log.info(f"[CacheService] Attempting to delete cache pattern: {pattern}") + redis = get_redis() + if not redis: + return 0 + + try: + keys = await redis.keys(pattern) + log.info(f"[CacheService] Found {len(keys) if keys else 0} keys matching pattern '{pattern}'") + if keys: + result = await redis.delete(*keys) + log.info(f"[CacheService] Pattern deletion result: {result} keys deleted for pattern '{pattern}'") + return result + log.info(f"[CacheService] No keys found for pattern '{pattern}'") + return 0 + except Exception as e: + log.error(f"根据模式删除缓存失败: {e}", exc_info=True) + return 0 + + @classmethod + async def exists(cls, key: str) -> bool: + """ + 检查缓存是否存在 + + Args: + key: 缓存键 + + Returns: + bool: 缓存是否存在 + """ + redis = get_redis() + if not redis: + return False + + try: + return await redis.exists(key) > 0 + except Exception as e: + log.error(f"检查缓存存在失败: {e}", exc_info=True) + return False + + @classmethod + async def get_or_set(cls, key: str, func: Callable[..., T], ttl: Optional[int] = None, + *args, **kwargs) -> CacheResult[T]: + """ + 获取缓存,如果不存在则执行函数并缓存结果 + 实现了缓存穿透和击穿防护 + + Args: + key: 缓存键 + func: 获取数据的函数 + ttl: 过期时间(秒) + *args: 函数参数 + **kwargs: 函数关键字参数 + + Returns: + CacheResult: 缓存结果 + """ + # 尝试从缓存获取 + cached_value = await cls.get(key) + if cached_value is not None: + return CacheResult(data=cached_value, from_cache=True, ttl=ttl) + + # 缓存穿透防护: 如果函数返回None,也缓存一个空值,设置较短的TTL + try: + # 使用分布式锁防止缓存击穿 + lock_key = f"lock:{key}" + lock_acquired = await cls._acquire_lock(lock_key) + + if lock_acquired: + try: + # 再次检查缓存,防止锁竞争期间其他请求已设置缓存 + cached_value = await cls.get(key) + if cached_value is not None: + return CacheResult(data=cached_value, from_cache=True, ttl=ttl) + + # 执行函数获取数据 + data = await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs) + + if data is not None: + # 缓存正常数据,使用指定TTL + await cls.set(key, data, ttl) + return CacheResult(data=data, from_cache=False, ttl=ttl) + else: + # 缓存空值,防止缓存穿透 + await cls.set(key, "", cls.PENETRATION_TTL) + return CacheResult(data=None, from_cache=False, ttl=cls.PENETRATION_TTL) + finally: + # 释放锁 + await cls._release_lock(lock_key) + else: + # 获取锁失败,等待一段时间后重试 + await asyncio.sleep(0.1) + return await cls.get_or_set(key, func, ttl, *args, **kwargs) + except Exception as e: + log.error(f"get_or_set 缓存操作失败: {e}", exc_info=True) + # 如果获取数据失败,返回默认值,不缓存 + return CacheResult(data=None, from_cache=False) + + @classmethod + async def _acquire_lock(cls, key: str, ttl: int = LOCK_DEFAULT_TTL) -> bool: + """ + 获取分布式锁 + + Args: + key: 锁键 + ttl: 锁过期时间 + + Returns: + bool: 是否获取到锁 + """ + redis = get_redis() + if not redis: + return False + + try: + # 使用SETNX实现分布式锁 + result = await redis.set(key, "1", nx=True, ex=ttl) + return result is True + except Exception as e: + log.error(f"获取分布式锁失败: {e}", exc_info=True) + return False + + @classmethod + async def _release_lock(cls, key: str) -> bool: + """ + 释放分布式锁 + + Args: + key: 锁键 + + Returns: + bool: 是否释放成功 + """ + redis = get_redis() + if not redis: + return False + + try: + await redis.delete(key) + return True + except Exception as e: + log.error(f"释放分布式锁失败: {e}", exc_info=True) + return False + + @classmethod + async def hash_get(cls, key: str, field: str, default: Any = None) -> Optional[Any]: + """ + 获取哈希表字段值 + + Args: + key: 缓存键 + field: 字段名 + default: 默认值 + + Returns: + Any: 字段值或默认值 + """ + redis = get_redis() + if not redis: + return default + + try: + value = await redis.hget(key, field) + if value is None: + return default + + # 尝试反序列化为JSON对象 + try: + if isinstance(value, bytes): + value = value.decode('utf-8') + if isinstance(value, str) and (value.startswith('{') or value.startswith('[')): + return json.loads(value) + return value + except json.JSONDecodeError: + # 不是JSON,返回原始值 + return value + except Exception as e: + log.error(f"获取哈希表字段失败: {e}", exc_info=True) + return default + + @classmethod + async def hash_set(cls, key: str, field: str, value: Any, ttl: Optional[int] = None) -> bool: + """ + 设置哈希表字段值 + + Args: + key: 缓存键 + field: 字段名 + value: 字段值 + ttl: 过期时间(秒) + + Returns: + bool: 是否设置成功 + """ + redis = get_redis() + if not redis: + return False + + try: + # 序列化复杂对象为JSON + if value is not None and not isinstance(value, (str, int, float, bool)): + # 检查是否有model_dump方法(Pydantic模型) + if hasattr(value, 'model_dump'): + value = value.model_dump() + # 检查是否有dict方法 + elif hasattr(value, 'dict'): + value = value.dict() + value = json.dumps(value, default=str) # default=str 处理datetime等特殊类型 + + await redis.hset(key, field, value) + if ttl: + await redis.expire(key, ttl) + return True + except Exception as e: + log.error(f"设置哈希表字段失败: {e}", exc_info=True) + return False + + @classmethod + async def hash_delete(cls, key: str, field: str) -> bool: + """ + 删除哈希表字段 + + Args: + key: 缓存键 + field: 字段名 + + Returns: + bool: 是否删除成功 + """ + redis = get_redis() + if not redis: + return False + + try: + await redis.hdel(key, field) + return True + except Exception as e: + log.error(f"删除哈希表字段失败: {e}", exc_info=True) + return False + + @classmethod + def generate_cache_key(cls, prefix: str, *parts: Any) -> str: + """ + 生成统一格式的缓存键 + + Args: + prefix: 缓存键前缀 + *parts: 缓存键的各个部分 + + Returns: + str: 完整的缓存键 + """ + # 将所有部分转换为字符串 + str_parts = [str(part) for part in parts] + # 使用redis_key_manager生成统一格式的键 + return redis_key_manager.generate_key(*str_parts, prefix=prefix) + + +# 导出全局缓存服务实例 +cache_service = CacheService() diff --git a/src/backend/app/redis_client/expiration_listener.py b/src/backend/app/redis_client/expiration_listener.py new file mode 100644 index 0000000..b7dc58f --- /dev/null +++ b/src/backend/app/redis_client/expiration_listener.py @@ -0,0 +1,95 @@ +""" +Redis 过期事件监听器。 + +本模块实现了对 Redis 键过期事件 (expired events) 的监听和处理机制。 +主要用于处理 Token 过期、验证码失效等需要触发后续业务逻辑的场景。 +""" + +# backend/app/redis_client/expiration_listener.py + +import asyncio +import aioredis + +from aioredis.client import PubSub + +from service.redis_expire_handle_service import service_handle_expire_token +from redis_client.redis_keys import redis_key_manager +from core.log import log +from core.config import config + +async def redis_expire_handler(expire_key: str): + """ + Redis 过期键事件分发处理器。 + + 根据过期键的前缀将事件分发给对应的业务逻辑处理。 + + Args: + expire_key (str): 已过期的 Redis 键名。 + """ + log.info(f"Expired key received: {expire_key}") + + # 获取基础前缀(用于环境隔离) + base_prefix = redis_key_manager._get_base_prefix() + prefix_len = len(base_prefix) + 1 if base_prefix else 0 + + # 提取实际键名(去除基础前缀) + actual_key = expire_key[prefix_len:] if base_prefix else expire_key + + verification_prefix = redis_key_manager.VERIFICATION_PREFIX + token_prefix = redis_key_manager.TOKEN_PREFIX + + if actual_key.startswith(f"{verification_prefix}:"): + log.info(f"Verification code expired: {expire_key}") + elif actual_key.startswith(f"{token_prefix}:"): + token = actual_key[len(f"{token_prefix}:"):] + log.info(f"Token expired: {token}") + await service_handle_expire_token(token) # Added await + + +async def redis_expire_listener(): + """ + 启动 Redis 过期事件监听循环。 + + 建立一个独立的 Redis 连接,订阅 `__keyevent@*__:expired` 频道, + 实时监听所有数据库的键过期事件,并将其投递给 `redis_expire_handler` 进行处理。 + 此函数会无限循环运行,直到程序退出。 + """ + # Create a separate Redis connection for listening + redis_conn = aioredis.from_url( + f"redis://{config.redis.host}:{config.redis.port}/{config.redis.db}", + password=config.redis.password, + encoding="utf-8", + decode_responses=True, + socket_connect_timeout=config.redis.connect_timeout, + socket_timeout=config.redis.read_timeout # For aioredis 2.x + ) + + try: + # Enable keyspace notifications for expired events + await redis_conn.config_set("notify-keyspace-events", "Ex") + + pubsub = redis_conn.pubsub() + # Use the correct pattern for key expiration events + await pubsub.psubscribe("__keyevent@*__:expired") + + log.info("Redis expiration listener started, waiting for events...") + + while True: + try: + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0) + if message: + log.info(f"Received message: {message}") + expire_key = message["data"] + log.info(f"Processing expired key: {expire_key}") + asyncio.create_task(redis_expire_handler(expire_key)) + except Exception as e: + log.error(f"Error in Redis expire listener: {e}") + await asyncio.sleep(3) + + except Exception as e: + log.error(f"无法启动Redis过期监听器: {e}") + finally: + try: + await redis_conn.close() + except: + pass \ No newline at end of file diff --git a/src/backend/app/redis_client/redis.py b/src/backend/app/redis_client/redis.py new file mode 100644 index 0000000..c75e096 --- /dev/null +++ b/src/backend/app/redis_client/redis.py @@ -0,0 +1,222 @@ +""" +Redis 连接管理器。 + +本模块负责创建和管理 Redis 连接池,提供全局的 Redis 客户端实例。 +支持常规数据操作连接和 Pub/Sub 监听专用连接的独立管理。 +""" + +# backend/app/redis_client/redis_client.py + +import aioredis +import asyncio + +from core.config import config +from core.log import log + +redis = None +redis_listener = None + +async def init_redis(): + """ + 初始化 Redis 连接池。 + + 创建一个全局的 Redis 连接池实例,用于常规的数据读写操作。 + 配置参数从全局配置中读取,支持密码、超时、连接池、哨兵模式和SSL设置。 + """ + global redis + + try: + # 根据配置选择连接方式 + if config.redis.sentinel_enabled: + # 哨兵模式连接 + sentinel_kwargs = { + 'sentinels': [(host, int(port)) for host, port in (s.split(':') for s in config.redis.sentinels)], + 'sentinel_kwargs': { + 'password': config.redis.password, + 'socket_connect_timeout': config.redis.connect_timeout, + 'socket_timeout': config.redis.read_timeout, + }, + 'encoding': 'utf-8', + 'decode_responses': True, + 'socket_connect_timeout': config.redis.connect_timeout, + 'socket_timeout': config.redis.read_timeout, + 'max_connections': config.redis.max_connections, + 'retry_on_timeout': True, + 'health_check_interval': config.redis.health_check_interval + } + + # 创建哨兵客户端 + sentinel = aioredis.sentinel.Sentinel( + sentinel_kwargs['sentinels'], + sentinel_kwargs['sentinel_kwargs'], + encoding=sentinel_kwargs['encoding'], + decode_responses=sentinel_kwargs['decode_responses'] + ) + + # 获取主节点连接 + redis = sentinel.master_for( + config.redis.master_name, + db=config.redis.db, + socket_connect_timeout=sentinel_kwargs['socket_connect_timeout'], + socket_timeout=sentinel_kwargs['socket_timeout'], + max_connections=sentinel_kwargs['max_connections'], + retry_on_timeout=sentinel_kwargs['retry_on_timeout'], + health_check_interval=sentinel_kwargs['health_check_interval'] + ) + else: + # 单节点连接 + redis_url = f"redis://{config.redis.host}:{config.redis.port}/{config.redis.db}" + + # 构建连接参数 + connection_kwargs = { + "encoding": "utf-8", + "decode_responses": True, + "socket_connect_timeout": config.redis.connect_timeout, + "socket_timeout": config.redis.read_timeout, # For aioredis 2.x + "max_connections": config.redis.max_connections, + "retry_on_timeout": True, + "health_check_interval": config.redis.health_check_interval + } + + # 仅当设置了密码时才添加密码参数 + if config.redis.password: + connection_kwargs["password"] = config.redis.password + + # 仅当启用SSL时添加SSL参数 + if config.redis.ssl: + connection_kwargs["ssl"] = config.redis.ssl + connection_kwargs["ssl_cert_reqs"] = config.redis.ssl_cert_reqs + + redis = aioredis.from_url( + redis_url, + **connection_kwargs + ) + + await redis.ping() + log.info("Connected to Redis") + except Exception as e: + log.error(f"无法连接到Redis: {e}") + # 实现连接重试 + retry_count = 0 + while retry_count < config.redis.max_retries: + try: + retry_count += 1 + log.info(f"第{retry_count}次重试连接Redis...") + await asyncio.sleep(config.redis.retry_interval) + + if config.redis.sentinel_enabled: + sentinel = aioredis.sentinel.Sentinel( + [(host, int(port)) for host, port in (s.split(':') for s in config.redis.sentinels)], + sentinel_kwargs={'password': config.redis.password}, + encoding='utf-8', + decode_responses=True + ) + redis = sentinel.master_for(config.redis.master_name, db=config.redis.db) + else: + redis_url = f"redis://{config.redis.host}:{config.redis.port}/{config.redis.db}" + redis = aioredis.from_url( + redis_url, + password=config.redis.password, + encoding="utf-8", + decode_responses=True, + socket_connect_timeout=config.redis.connect_timeout, + socket_timeout=config.redis.read_timeout # For aioredis 2.x + ) + + await redis.ping() + log.info(f"第{retry_count}次重试连接Redis成功") + break + except Exception as retry_e: + log.error(f"第{retry_count}次重试连接Redis失败: {retry_e}") + if retry_count == config.redis.max_retries: + log.error(f"已达到最大重试次数({config.redis.max_retries}),无法连接到Redis") + +async def init_redis_listener(): + """ + 初始化 Redis 监听器专用连接。 + + 由于 Redis 的 Pub/Sub(发布订阅)模式和阻塞命令需要独占连接, + 因此单独创建一个连接实例用于监听键过期等事件。 + """ + global redis_listener + # 构建 Redis URL + url = f"redis://{config.redis.host}:{config.redis.port}/{config.redis.db}" + + # Create a separate connection for listening to events + redis_listener = aioredis.from_url( + url, + password=config.redis.password, + encoding="utf-8", + decode_responses=True, + socket_connect_timeout=config.redis.connect_timeout, + socket_timeout=config.redis.read_timeout, # For aioredis 2.x + max_connections=1 # 监听器只需要一个连接 + ) + + try: + # Enable keyspace notifications for expired events + await redis_listener.config_set("notify-keyspace-events", "Ex") + await redis_listener.ping() + log.info("Connected to Redis listener") + except Exception as e: + log.error(f"无法连接到Redis监听器: {e}") + +def get_redis(): + """ + 获取全局 Redis 客户端实例。 + + Returns: + aioredis.Redis: Redis 客户端实例。如果未初始化则返回 None。 + """ + global redis + + if not redis: + log.warning("Redis is not initialized") + return None + return redis + +def get_redis_listener(): + """ + 获取 Redis 监听器专用连接实例。 + + Returns: + aioredis.Redis: 监听器专用的 Redis 连接实例。 + """ + global redis_listener + + if not redis_listener: + log.warning("Redis listener is not initialized") + return None + return redis_listener + +async def close_redis(): + """ + 关闭 Redis 连接池。 + + 释放常规操作的 Redis 连接资源。 + """ + global redis + if redis: + try: + await redis.close() + log.info("Closed Redis connection") + except Exception as e: + log.error(f"无法关闭Redis连接: {e}") + finally: + redis = None + +async def close_redis_listener(): + """ + 关闭 Redis 监听器连接。 + + 释放用于事件监听的专用 Redis 连接资源。 + """ + global redis_listener + if redis_listener: + try: + await redis_listener.close() + log.info("Closed Redis listener connection") + except Exception as e: + log.error(f"无法关闭Redis监听器连接: {e}") + finally: + redis_listener = None \ No newline at end of file diff --git a/src/backend/app/redis_client/redis_keys.py b/src/backend/app/redis_client/redis_keys.py new file mode 100644 index 0000000..19a291c --- /dev/null +++ b/src/backend/app/redis_client/redis_keys.py @@ -0,0 +1,445 @@ +""" +Redis 键前缀管理系统 + +该模块提供统一的 Redis 键生成和管理功能,确保所有 Redis 键遵循一致的命名规范。 +支持环境隔离、键分类和统一的键生成接口。 +""" + +from typing import Optional, Any +from core.config import config + + +class RedisKeyManager: + """ + Redis 键管理器类 + + 提供统一的键生成方法,支持环境隔离和键分类管理。 + """ + + # 键前缀常量(按功能分类) + # 认证相关 + TOKEN_PREFIX = "token" + # 公告相关 + ANNOUNCEMENT_PREFIX = "announcement" + # 密码重置相关 + PASSWORD_RESET_PREFIX = "pwdreset" + # 验证码相关 + VERIFICATION_PREFIX = "verification" + # 频率限制相关 + FREQUENCY_LIMIT_PREFIX = "freq" + # 用户相关 + USER_PREFIX = "user" + # 系统相关 + SYSTEM_PREFIX = "system" + # 项目相关 + PROJECT_PREFIX = "project" + # 数据库实例相关 + DATABASE_PREFIX = "db" + # SQL执行相关 + SQL_PREFIX = "sql" + # 缓存相关 + CACHE_PREFIX = "cache" + # API响应相关 + API_PREFIX = "api" + # 配置相关 + CONFIG_PREFIX = "config" + + @classmethod + def _get_base_prefix(cls) -> str: + """ + 获取基础前缀(包含环境隔离前缀) + + Returns: + str: 基础前缀 + """ + return config.redis.key_prefix + + @classmethod + def generate_key(cls, *parts: str, prefix: Optional[str] = None) -> str: + """ + 生成统一格式的 Redis 键 + + Args: + *parts: 键的各个部分 + prefix: 可选的自定义前缀 + + Returns: + str: 完整的 Redis 键 + """ + # 构建键的所有部分 + key_parts = [] + + # 添加基础前缀(用于环境隔离) + base_prefix = cls._get_base_prefix() + if base_prefix: + key_parts.append(base_prefix) + + # 添加自定义前缀 + if prefix: + key_parts.append(prefix) + + # 添加其他部分 + key_parts.extend(parts) + + # 使用冒号连接所有部分 + return ":".join(key_parts) + + # 认证相关键生成方法 + @classmethod + def get_token_key(cls, token: str) -> str: + """ + 生成 Token 键 + + Args: + token: Token 值 + + Returns: + str: Token 键 + """ + return cls.generate_key(cls.TOKEN_PREFIX, token) + + # 公告相关键生成方法 + @classmethod + def get_announcement_list_key(cls, page: int, page_size: int) -> str: + """ + 生成公告列表键 + + Args: + page: 页码 + page_size: 每页数量 + + Returns: + str: 公告列表键 + """ + return cls.generate_key( + cls.ANNOUNCEMENT_PREFIX, + "list", + "published", + f"page_{page}", + f"size_{page_size}" + ) + + @classmethod + def get_announcement_detail_key(cls, announcement_id: int) -> str: + """ + 生成公告详情键 + + Args: + announcement_id: 公告ID + + Returns: + str: 公告详情键 + """ + return cls.generate_key(cls.ANNOUNCEMENT_PREFIX, "detail", str(announcement_id)) + + @classmethod + def get_announcement_list_pattern(cls) -> str: + """ + 生成公告列表键的匹配模式 + + Returns: + str: 公告列表键匹配模式 + """ + return cls.generate_key(cls.ANNOUNCEMENT_PREFIX, "list", "published", "*") + + @classmethod + def get_announcement_detail_pattern(cls) -> str: + """ + 生成公告详情键的匹配模式 + + Returns: + str: 公告详情键匹配模式 + """ + return cls.generate_key(cls.ANNOUNCEMENT_PREFIX, "detail", "*") + + # 密码重置相关键生成方法 + @classmethod + def get_password_reset_key(cls, email: str) -> str: + """ + 生成密码重置验证码键 + + Args: + email: 用户邮箱 + + Returns: + str: 密码重置验证码键 + """ + return cls.generate_key(cls.PASSWORD_RESET_PREFIX, "code", email) + + # 验证码相关键生成方法 + @classmethod + def get_verification_key(cls, email: str) -> str: + """ + 生成验证码键 + + Args: + email: 用户邮箱 + + Returns: + str: 验证码键 + """ + return cls.generate_key(cls.VERIFICATION_PREFIX, email) + + # 登录失败计数相关键生成方法 + @classmethod + def get_login_fail_count_key(cls, user_id: int) -> str: + """ + 生成登录失败计数键 + + 用于记录用户连续输错密码的次数,24小时后自动过期。 + + Args: + user_id: 用户ID + + Returns: + str: 登录失败计数键 + """ + return cls.generate_key(cls.USER_PREFIX, "login_fail", str(user_id)) + + # 频率限制相关键生成方法 + @classmethod + def get_frequency_limit_key(cls, user_id: int, action: Optional[str] = None) -> str: + """ + 生成频率限制键 + + Args: + user_id: 用户ID + action: 操作类型 + + Returns: + str: 频率限制键 + """ + if action: + return cls.generate_key(cls.FREQUENCY_LIMIT_PREFIX, cls.USER_PREFIX, str(user_id), action) + return cls.generate_key(cls.FREQUENCY_LIMIT_PREFIX, cls.USER_PREFIX, str(user_id)) + + # 密码重置相关键生成方法 + @classmethod + def get_password_reset_rate_limit_key(cls, email: str, client_ip: Optional[str] = None) -> str: + """ + 生成密码重置频率限制键 + + Args: + email: 用户邮箱 + client_ip: 客户端IP + + Returns: + str: 频率限制键 + """ + if client_ip: + return cls.generate_key(cls.PASSWORD_RESET_PREFIX, "rl", "ip", client_ip) + return cls.generate_key(cls.PASSWORD_RESET_PREFIX, "rl", "email", email.lower().strip()) + + @classmethod + def get_password_reset_code_key(cls, email: str) -> str: + """ + 生成密码重置验证码键 + + Args: + email: 用户邮箱 + + Returns: + str: 密码重置验证码键 + """ + return cls.generate_key(cls.PASSWORD_RESET_PREFIX, "code", email.lower().strip()) + + # 用户相关键生成方法 + @classmethod + def get_user_info_key(cls, user_id: int) -> str: + """ + 生成用户信息键 + + Args: + user_id: 用户ID + + Returns: + str: 用户信息键 + """ + return cls.generate_key(cls.USER_PREFIX, "info", str(user_id)) + + @classmethod + def get_user_projects_key(cls, user_id: int) -> str: + """ + 生成用户项目列表键 + + Args: + user_id: 用户ID + + Returns: + str: 用户项目列表键 + """ + return cls.generate_key(cls.USER_PREFIX, "projects", str(user_id)) + + @classmethod + def get_user_databases_key(cls, user_id: int) -> str: + """ + 生成用户数据库列表键 + + Args: + user_id: 用户ID + + Returns: + str: 用户数据库列表键 + """ + return cls.generate_key(cls.USER_PREFIX, "databases", str(user_id)) + + @classmethod + def get_project_list_key(cls, user_id: int, search: Optional[str] = None, + page: int = 1, page_size: int = 10) -> str: + """ + 生成用户项目列表键(包含搜索条件和分页参数) + + Args: + user_id: 用户ID + search: 搜索条件 + page: 页码 + page_size: 页大小 + + Returns: + str: 用户项目列表键 + """ + # 处理搜索条件,None时使用空字符串 + search_str = search or "" + return cls.generate_key(cls.USER_PREFIX, "projects", str(user_id), + f"search:{search_str}", f"page:{page}", f"size:{page_size}") + + # 项目相关键生成方法 + @classmethod + def get_project_info_key(cls, project_id: int) -> str: + """ + 生成项目信息键 + + Args: + project_id: 项目ID + + Returns: + str: 项目信息键 + """ + return cls.generate_key(cls.PROJECT_PREFIX, "info", str(project_id)) + + @classmethod + def get_project_members_key(cls, project_id: int) -> str: + """ + 生成项目成员列表键 + + Args: + project_id: 项目ID + + Returns: + str: 项目成员列表键 + """ + return cls.generate_key(cls.PROJECT_PREFIX, "members", str(project_id)) + + @classmethod + def get_project_databases_key(cls, project_id: int) -> str: + """ + 生成项目数据库列表键 + + Args: + project_id: 项目ID + + Returns: + str: 项目数据库列表键 + """ + return cls.generate_key(cls.PROJECT_PREFIX, "databases", str(project_id)) + + # 数据库实例相关键生成方法 + @classmethod + def get_database_info_key(cls, db_id: int) -> str: + """ + 生成数据库实例信息键 + + Args: + db_id: 数据库实例ID + + Returns: + str: 数据库实例信息键 + """ + return cls.generate_key(cls.DATABASE_PREFIX, "info", str(db_id)) + + @classmethod + def get_database_schema_key(cls, db_id: int) -> str: + """ + 生成数据库模式键 + + Args: + db_id: 数据库实例ID + + Returns: + str: 数据库模式键 + """ + return cls.generate_key(cls.DATABASE_PREFIX, "schema", str(db_id)) + + # SQL执行相关键生成方法 + @classmethod + def get_sql_result_key(cls, sql_hash: str) -> str: + """ + 生成SQL执行结果键 + + Args: + sql_hash: SQL语句的哈希值 + + Returns: + str: SQL执行结果键 + """ + return cls.generate_key(cls.SQL_PREFIX, "result", sql_hash) + + @classmethod + def get_sql_template_key(cls, template_id: int) -> str: + """ + 生成SQL模板键 + + Args: + template_id: SQL模板ID + + Returns: + str: SQL模板键 + """ + return cls.generate_key(cls.SQL_PREFIX, "template", str(template_id)) + + # 通用缓存键生成方法 + @classmethod + def get_cache_key(cls, resource_type: str, resource_id: Any) -> str: + """ + 生成通用缓存键 + + Args: + resource_type: 资源类型 + resource_id: 资源ID + + Returns: + str: 通用缓存键 + """ + return cls.generate_key(cls.CACHE_PREFIX, resource_type, str(resource_id)) + + @classmethod + def get_api_response_key(cls, endpoint: str, params_hash: str) -> str: + """ + 生成API响应缓存键 + + Args: + endpoint: API端点 + params_hash: 参数哈希值 + + Returns: + str: API响应缓存键 + """ + return cls.generate_key(cls.API_PREFIX, endpoint, params_hash) + + # 系统配置相关键生成方法 + @classmethod + def get_system_config_key(cls, config_name: str) -> str: + """ + 生成系统配置键 + + Args: + config_name: 配置名称 + + Returns: + str: 系统配置键 + """ + return cls.generate_key(cls.SYSTEM_PREFIX, cls.CONFIG_PREFIX, config_name) + + +# 导出全局键管理器实例 +redis_key_manager = RedisKeyManager() diff --git a/src/backend/app/schema/__init__.py b/src/backend/app/schema/__init__.py new file mode 100644 index 0000000..2388826 --- /dev/null +++ b/src/backend/app/schema/__init__.py @@ -0,0 +1,8 @@ +""" +Schema 模块初始化。 + +该模块包集中定义了应用层的所有 Pydantic 数据模型 (Schema)。 +用于 API 请求验证、响应序列化以及内部服务间的数据传输对象 (DTO) 定义。 +""" + +# backend/app/schema/__init__.py diff --git a/src/backend/app/schema/admin.py b/src/backend/app/schema/admin.py new file mode 100644 index 0000000..ff1be0b --- /dev/null +++ b/src/backend/app/schema/admin.py @@ -0,0 +1,321 @@ +""" +管理员管理模块 Schema。 + +本模块定义了管理员进行用户管理、封禁、解封以及获取系统统计数据所需的 Pydantic 模型。 +包含分页请求、封禁操作、解封审批及各类响应体结构。 +""" + +# backend/app/schema/admin.py + +from pydantic import BaseModel, Field, ConfigDict, validator, root_validator +from typing import Optional, List, Literal, Any +from datetime import datetime +from schema.user import UserMe # 确保这里能导入 UserMe +from schema.announcement import AnnouncementResponse # 导入统一的公告响应模型 + + +# ---------------------------------------------------------------------- +# 0. 通用分页基类 +# ---------------------------------------------------------------------- +class PaginatedResponseSchema(BaseModel): + """ + 通用分页响应基类。 + + Attributes: + total (int): 总记录数。 + page (int): 当前页码。 + page_size (int): 每页记录数。 + """ + total: int = Field(..., description="总记录数") + page: int = Field(..., description="当前页码") + page_size: int = Field(..., description="每页记录数") + + model_config = ConfigDict(from_attributes=True) + + +# ---------------------------------------------------------------------- +# 4.1. 用户管理 Schemas +# ---------------------------------------------------------------------- + +# 4.1.1. 列表单项 DTO +class AdminUserListItem(BaseModel): + """ + 管理员视角的用户列表单项。 + + Attributes: + user_id (int): 用户 ID。 + username (str): 用户名。 + email (str): 邮箱。 + status (str): 用户状态。 + project_count (int): 该用户拥有的项目数量。 + max_databases (int): 该用户最大数据库额度。 + last_login_at (Optional[datetime]): 最后登录时间。 + """ + user_id: int + username: str + email: str + status: Literal["normal", "suspended", "banned"] + project_count: int = Field(default=0, description="该用户拥有的项目数量") + max_databases: int = Field(default=10, description="该用户最大数据库额度") + last_login_at: Optional[datetime] = None + avatar_url: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +# 4.1.1. 用户列表响应 +class AdminUserListResponse(PaginatedResponseSchema): + """ + 用户列表响应 Schema。 + + Attributes: + items (List[AdminUserListItem]): 用户列表项。 + """ + items: List[AdminUserListItem] + + +# 4.1.2. 用户状态 - Request +class AdminUpdateUserStatusRequest(BaseModel): + """ + 修改用户状态请求 Schema。 + + Attributes: + status (str): 新状态。 + reason (str): 操作原因。 + """ + status: Literal["normal", "banned", "suspended"] + reason: str = Field(..., description="操作原因") + + +# 4.1.2. 用户状态 - Response (新增,解决 ImportError) +class AdminUpdateUserStatusResponse(BaseModel): + """ + 修改用户状态响应 Schema。 + + Attributes: + user_id (int): 用户 ID。 + status (str): 更新后的状态。 + updated_at (datetime): 更新时间。 + """ + user_id: int + status: Literal["normal", "suspended", "banned"] + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +# 4.1.3. 调整用户资源额度 - Request +class AdminUpdateUserQuotaRequest(BaseModel): + """ + 调整用户资源额度请求 Schema。 + + Attributes: + max_databases (int): 新的最大数据库额度。 + """ + max_databases: int = Field(..., description="新的最大数据库额度") + + @validator('max_databases') + def max_databases_positive(cls, v): + if v <= 0: + raise ValueError('最大数据库额度必须大于0') + return v + + +# 4.1.3. 调整用户资源额度 - Response (新增,解决 ImportError) +class AdminUpdateUserQuotaResponse(BaseModel): + """ + 调整用户资源额度响应 Schema。 + + Attributes: + user_id (int): 用户 ID。 + max_databases (int): 更新后的最大数据库额度。 + updated_at (datetime): 更新时间。 + """ + user_id: int + max_databases: int + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +# 4.1.4 用户详情 - Project DTO +class AdminUserProjectItem(BaseModel): + """管理员视角的用户项目条目。""" + + project_id: int + project_name: str + db_type: Optional[str] = None + project_status: str + created_at: datetime + description: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +# 4.1.4 用户详情 - Login History DTO +class AdminUserLoginHistoryItem(BaseModel): + """管理员视角的用户登录历史条目。""" + + login_id: int + login_time: datetime + logout_time: Optional[datetime] = None + ip_address: str + user_agent: Optional[str] = None + login_status: str + failure_reason: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +# 4.1.4 用户详情 - Response +class AdminUserDetailResponse(BaseModel): + """管理员查看用户详情响应。""" + + user_id: int + username: str + email: str + status: Literal["normal", "suspended", "banned"] + max_databases: int = Field(default=10, description="最大数据库额度") + project_count: int = Field(default=0, description="项目数量") + last_login_at: Optional[datetime] = None + created_at: Optional[datetime] = None + avatar_url: Optional[str] = None + + projects: List[AdminUserProjectItem] = Field(default_factory=list) + login_history: List[AdminUserLoginHistoryItem] = Field(default_factory=list) + + model_config = ConfigDict(from_attributes=True) + + +# ---------------------------------------------------------------------- +# 4.2. 公告管理 Schemas +# ---------------------------------------------------------------------- + +class AnnouncementCreateRequest(BaseModel): + """ + 创建公告请求 Schema。 + + Attributes: + title (str): 公告标题。 + content (str): 公告内容。 + status (str): 公告状态 (默认 "published")。 + """ + title: str = Field(..., description="公告标题") + content: str + status: Literal['draft', 'published'] = 'published' + + @validator('title') + def title_length(cls, v): + if not 1 <= len(v) <= 100: + raise ValueError('公告标题长度必须在1到100个字符之间') + return v + + +class AnnouncementUpdateRequest(BaseModel): + """ + 更新公告请求 Schema。 + + Attributes: + title (Optional[str]): 公告标题。 + content (Optional[str]): 公告内容。 + status (Optional[str]): 公告状态。 + """ + title: Optional[str] = None + content: Optional[str] = None + status: Optional[Literal["draft", "published", "unpublished", "expired"]] = None + + +# ---------------------------------------------------------------------- +# 4.3. 管理员列表 & 4.4. 违规/统计 Schemas +# ---------------------------------------------------------------------- + +# 管理员列表单项 +class AdminListItem(BaseModel): + """ + 管理员列表单项 Schema。 + + 与前端 AdminListItem 接口保持一致的字段结构。 + + Attributes: + user_id (int): 用户 ID。 + username (str): 用户名。 + email (str): 邮箱。 + last_login_at (Optional[str]): 最后登录时间(ISO格式字符串)。 + is_online (bool): 是否在线(基于 Redis 实时状态)。 + """ + user_id: int + username: str + email: str + avatar_url: Optional[str] = None + last_login_at: Optional[str] = None # 前端期望字符串格式 + is_online: bool = Field(default=False, description="是否在线状态(基于 Redis 实时状态)") + + model_config = ConfigDict(from_attributes=True) + + +# 管理员列表响应 +class AdminListResponse(PaginatedResponseSchema): + """ + 管理员列表响应 Schema。 + + Attributes: + items (List[AdminListItem]): 管理员列表项。 + """ + items: List[AdminListItem] + + +# 违规记录单项 +class ViolationLogListItem(BaseModel): + """ + 违规记录列表单项 Schema。 + + Attributes: + violation_id (int): 违规记录 ID。 + user_id (int): 用户 ID。 + username (str): 用户名。 + event_type (str): 违规事件类型。 + event_description (str): 事件详细描述。 + risk_level (str): 风险等级。 + resolution_status (str): 处理状态。 + created_at (datetime): 创建时间。 + """ + violation_id: int + user_id: int + username: str + event_type: str + event_description: str + risk_level: str + resolution_status: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +# 违规记录列表响应 +class ViolationLogListResponse(PaginatedResponseSchema): + """ + 违规记录列表响应 Schema。 + + Attributes: + items (List[ViolationLogListItem]): 违规记录列表项。 + """ + items: List[ViolationLogListItem] + + +# 系统统计响应 +class AdminStatsResponse(BaseModel): + """ + 系统统计响应 Schema。 + + Attributes: + active_users_today (int): 今日活跃用户数。 + total_projects (int): 项目总数。 + query_count_today (int): 今日查询数。 + high_risk_operations_today (int): 今日高风险操作数。 + system_health (str): 系统健康状况。 + """ + active_users_today: int + total_projects: int + query_count_today: int + high_risk_operations_today: int + system_health: Literal["good", "warning", "critical"] \ No newline at end of file diff --git a/src/backend/app/schema/ai_model_config.py b/src/backend/app/schema/ai_model_config.py new file mode 100644 index 0000000..69127d8 --- /dev/null +++ b/src/backend/app/schema/ai_model_config.py @@ -0,0 +1,167 @@ +""" +AI 模型配置 Schema(精简版)。 + +本模块定义了 AI 模型配置管理所需的 Pydantic 模型。 +包含创建、更新、查询等操作的请求和响应结构。 +""" + +# backend/app/schema/ai_model_config.py + +from pydantic import BaseModel, Field, ConfigDict +from typing import Optional, List +from datetime import datetime + + +# ---------------------------------------------------------------------- +# 创建请求 +# ---------------------------------------------------------------------- + +class AIModelConfigCreate(BaseModel): + """ + 创建 AI 模型配置请求 Schema。 + + Attributes: + model_name (str): 模型名称(唯一标识)。 + api_url (str): API 接口地址。 + model_id (str): 模型在 API 服务中的标识。 + api_key (str): API 密钥。 + model_type (str): 模型类型。 + """ + model_name: str = Field(..., min_length=1, max_length=200, description="模型名称") + api_url: str = Field(..., min_length=1, max_length=500, description="API 接口地址") + model_id: str = Field(..., min_length=1, max_length=200, description="模型在 API 服务中的标识") + api_key: str = Field(..., min_length=1, max_length=500, description="API 密钥") + model_type: str = Field(default="general_llm", description="模型类型:local_finetune, general_llm") + + # 禁用 Pydantic 保护命名空间,允许 model_ 前缀字段 + model_config = ConfigDict(protected_namespaces=()) + + +# ---------------------------------------------------------------------- +# 更新请求 +# ---------------------------------------------------------------------- + +class AIModelConfigUpdate(BaseModel): + """ + 更新 AI 模型配置请求 Schema。 + + 所有字段均为可选,仅更新提供的字段。 + """ + model_name: Optional[str] = Field(None, min_length=1, max_length=200, description="模型名称") + api_url: Optional[str] = Field(None, min_length=1, max_length=500, description="API 接口地址") + model_id: Optional[str] = Field(None, min_length=1, max_length=200, description="模型在 API 服务中的标识") + api_key: Optional[str] = Field(None, min_length=1, max_length=500, description="API 密钥") + model_type: Optional[str] = Field(None, description="模型类型") + + model_config = ConfigDict(from_attributes=True, protected_namespaces=()) + + +# ---------------------------------------------------------------------- +# 响应模型 +# ---------------------------------------------------------------------- + +class AIModelConfigResponse(BaseModel): + """ + AI 模型配置响应 Schema(不含敏感信息)。 + + Attributes: + config_id (int): 配置ID。 + model_name (str): 模型名称。 + api_url (str): API 接口地址。 + model_id (str): 模型在 API 服务中的标识。 + model_type (str): 模型类型。 + created_at (datetime): 创建时间。 + updated_at (datetime): 更新时间。 + """ + config_id: int + model_name: str + api_url: str + model_id: str + model_type: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True, protected_namespaces=()) + + +class AIModelConfigDetailResponse(AIModelConfigResponse): + """ + AI 模型配置详细响应 Schema(包含脱敏的 API Key)。 + + 用于管理员查看详情时,显示部分隐藏的 API Key。 + """ + api_key_masked: str = Field(..., description="脱敏后的 API 密钥") + + model_config = ConfigDict(from_attributes=True, protected_namespaces=()) + + +class AIModelConfigListResponse(BaseModel): + """ + AI 模型配置列表响应 Schema。 + + Attributes: + total (int): 总数。 + items (List[AIModelConfigResponse]): 配置列表。 + """ + total: int + items: List[AIModelConfigResponse] + + model_config = ConfigDict(from_attributes=True) + + +# ---------------------------------------------------------------------- +# 测试连接请求/响应 +# ---------------------------------------------------------------------- + +class AIModelTestConnectionRequest(BaseModel): + """ + 测试 AI 模型连接请求 Schema。 + + 支持两种模式: + 1. 新建模式:传递 api_key + 2. 编辑模式:传递 config_id,使用已保存的密钥 + """ + api_url: str = Field(..., min_length=1, max_length=500, description="API 接口地址") + api_key: Optional[str] = Field(None, max_length=500, description="API 密钥(新建模式必填)") + model_id: str = Field(..., min_length=1, max_length=200, description="模型 ID") + config_id: Optional[int] = Field(None, description="配置 ID(编辑模式下使用已保存的密钥)") + + model_config = ConfigDict(protected_namespaces=()) + + +class AIModelTestConnectionResponse(BaseModel): + """ + 测试 AI 模型连接响应 Schema。 + """ + success: bool = Field(..., description="连接是否成功") + message: str = Field(..., description="连接结果描述") + response_time_ms: Optional[int] = Field(None, description="响应时间(毫秒)") + + model_config = ConfigDict(protected_namespaces=()) + + +# ---------------------------------------------------------------------- +# 前端下拉选项响应 +# ---------------------------------------------------------------------- + +class AIModelOption(BaseModel): + """ + AI 模型选项(供前端下拉框使用)。 + + Attributes: + model_name (str): 模型名称(作为 value 和 label)。 + model_type (str): 模型类型。 + """ + model_name: str + model_type: str + + model_config = ConfigDict(from_attributes=True, protected_namespaces=()) + + +class AIModelOptionsResponse(BaseModel): + """ + AI 模型选项列表响应。 + """ + items: List[AIModelOption] + + model_config = ConfigDict(from_attributes=True) diff --git a/src/backend/app/schema/announcement.py b/src/backend/app/schema/announcement.py new file mode 100644 index 0000000..1db8ef3 --- /dev/null +++ b/src/backend/app/schema/announcement.py @@ -0,0 +1,76 @@ +""" +系统公告 Schema。 + +本模块定义了系统公告的创建、更新、查询及响应模型。 +用于管理员发布通知和用户查看公告。 +""" + +# backend/app/schema/announcement.py + +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import List, Optional + + +class AnnouncementBase(BaseModel): + """ + 公告基础 Schema。 + + Attributes: + title (str): 公告标题。 + content (str): 公告内容。 + status (str): 公告状态。 + """ + title: str + content: str + status: str + + +class AnnouncementResponse(BaseModel): + """ + 公告响应 Schema。 + + Attributes: + announcement_id (int): 公告 ID。 + title (str): 公告标题。 + content (str): 公告内容。 + status (str): 公告状态。 + created_at (datetime): 创建时间。 + updated_at (Optional[datetime]): 更新时间。 + created_by (Optional[int]): 创建人 ID。 + """ + announcement_id: int + title: str + content: str + status: str + created_at: datetime + updated_at: Optional[datetime] = None + created_by: Optional[int] = None + + # Pydantic v2 版本的 orm_mode + model_config = ConfigDict(from_attributes=True) + + +class AnnouncementListResponse(BaseModel): + """ + 公告列表响应 Schema。 + + Attributes: + total (int): 总记录数。 + page (int): 当前页码。 + page_size (int): 每页数量。 + items (List[AnnouncementResponse]): 公告列表。 + """ + total: int + page: int + page_size: int + items: List[AnnouncementResponse] + + model_config = ConfigDict(from_attributes=True) + + +class AnnouncementDetailResponse(AnnouncementResponse): + """ + 公告详情 Schema。 + """ + model_config = ConfigDict(from_attributes=True) diff --git a/src/backend/app/schema/auth.py b/src/backend/app/schema/auth.py new file mode 100644 index 0000000..ecf5c68 --- /dev/null +++ b/src/backend/app/schema/auth.py @@ -0,0 +1,136 @@ +""" +认证授权 Schema。 + +本模块定义了用户注册、登录、密码重置及验证码发送相关的请求和响应模型。 +""" + +from pydoc import describe +from pydantic import BaseModel, validator, Field + + +# 发送验证码 +class UserSendCode(BaseModel): + """ + 发送验证码请求 Schema。 + + Attributes: + email (str): 接收验证码的邮箱。 + """ + email: str = Field(..., description="邮箱") + + @validator('email') + def email_length(cls, v): + if not 3 <= len(v) <= 50: + raise ValueError('邮箱长度必须在3到50个字符之间') + return v + +# 用户注册 +class UserRegister(BaseModel): + """ + 用户注册请求 Schema。 + + Attributes: + username (str): 用户名。 + email (str): 邮箱。 + password (str): 密码。 + confirm_password (str): 确认密码。 + verification_code (str): 验证码。 + """ + username: str = Field(..., description="用户名") + email: str = Field(..., description="邮箱") + password: str = Field(..., description="密码") + confirm_password: str = Field(..., description="确认密码") + verification_code: str = Field(..., description="验证码") + + @validator('username') + def username_length(cls, v): + if not 3 <= len(v) <= 50: + raise ValueError('用户名长度必须在3到50个字符之间') + return v + + @validator('email') + def email_length(cls, v): + if not 3 <= len(v) <= 50: + raise ValueError('邮箱长度必须在3到50个字符之间') + return v + + @validator('password') + def password_length(cls, v): + if not 6 <= len(v) <= 50: + raise ValueError('密码长度必须在6到50个字符之间') + return v + + @validator('confirm_password') + def password_match(cls, v, values): + if 'password' in values and v != values['password']: + raise ValueError('两次输入的密码不一致') + return v + +# 用户登录 +class UserLogin(BaseModel): + """ + 用户登录请求 Schema。 + + Attributes: + username (str): 用户名。 + password (str): 密码。 + """ + username: str = Field(..., description="用户名") + password: str = Field(..., description="密码") + + @validator('username') + def username_length(cls, v): + if not 3 <= len(v) <= 50: + raise ValueError('用户名长度必须在3到50个字符之间') + return v + + @validator('password') + def password_length(cls, v): + if not 6 <= len(v) <= 50: + raise ValueError('密码长度必须在6到50个字符之间') + return v + + +class ForgotPasswordRequest(BaseModel): + """忘记密码:请求发送重置验证码邮件。""" + + email: str = Field(..., description="邮箱") + + @validator('email') + def email_length(cls, v): + if not 3 <= len(v) <= 50: + raise ValueError('邮箱长度必须在3到50个字符之间') + return v + + +class ResetPasswordRequest(BaseModel): + """重置密码:输入邮箱验证码后直接重置。""" + + email: str = Field(..., description="邮箱") + verification_code: str = Field(..., description="验证码") + new_password: str = Field(..., description="新密码") + confirm_password: str = Field(..., description="确认新密码") + + @validator('email') + def email_length(cls, v): + if not 3 <= len(v) <= 50: + raise ValueError('邮箱长度必须在3到50个字符之间') + return v + + @validator('verification_code') + def verification_code_length(cls, v): + if len(v) != 6: + raise ValueError('验证码必须是6个字符') + return v + + @validator('new_password') + def new_password_length(cls, v): + if not 6 <= len(v) <= 50: + raise ValueError('密码长度必须在6到50个字符之间') + return v + + @validator('confirm_password') + def password_match(cls, v, values): + if 'new_password' in values and v != values['new_password']: + raise ValueError('两次输入的密码不一致') + return v \ No newline at end of file diff --git a/src/backend/app/schema/chat.py b/src/backend/app/schema/chat.py new file mode 100644 index 0000000..fa10290 --- /dev/null +++ b/src/backend/app/schema/chat.py @@ -0,0 +1,67 @@ +""" +聊天交互 Schema。 + +本模块定义了用户与 AI 对话过程中的消息交互模型, +包含消息发送请求、AI 响应流式数据包以及消息确认机制。 +""" + +# backend/app/schema/chat.py + +from pydantic import BaseModel, Field, ConfigDict, validator +from typing import List, Optional, Any, Dict +from enum import Enum + +# 定义消息类型枚举 +class MessageType(str, Enum): + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" # 补充 system 类型,以防万一 + +# 1. 接收用户发送的消息 (Request DTO) +class ChatRequest(BaseModel): + """ + 聊天请求 Schema。 + + Attributes: + content (str): 用户输入的自然语言内容。 + model (Optional[str]): 指定使用的AI模型ID。 + """ + content: str = Field(..., description="用户输入的自然语言内容") + # 允许前端指定模型,可选值建议与后端 Registry 保持一致,但为了灵活先用 str + model: Optional[str] = Field(None, description="指定使用的AI模型ID,如 'xiyan-sql'") + + @validator('content') + def content_not_empty(cls, v): + if not v or len(v.strip()) == 0: + raise ValueError('消息内容不能为空') + return v + +# 2. 响应体:包含完整的对话信息 (Response DTO) +class ChatResponse(BaseModel): + """ + 聊天响应 Schema。 + + Attributes: + message_id (int): 消息ID。 + content (str): 消息内容。 + message_type (MessageType): 消息角色。 + sql_text (Optional[str]): 生成的 SQL 语句。 + sql_type (str): SQL 类型。 + requires_confirmation (bool): 是否需要用户确认执行。 + data (Optional[List[Dict[str, Any]]]): 数据结果。 + """ + message_id: int = Field(..., description="消息ID") + content: str = Field(..., description="消息内容") + message_type: MessageType = Field(..., description="消息角色") + + # SQL 相关信息 (Flattened 扁平化设计,方便前端直接读取) + sql_text: Optional[str] = Field(None, description="生成的 SQL 语句") + sql_type: str = Field("UNKNOWN", description="SQL 类型: SELECT/INSERT/UPDATE...") + + requires_confirmation: bool = Field(False, description="是否需要用户确认执行") + + # 数据结果 (动态结构) + # 对于动态表格数据,List[Dict] 是 Pydantic 中处理不确定列名的标准做法 + data: Optional[List[Dict[str, Any]]] = None + + model_config = ConfigDict(from_attributes=True) # 支持从 ORM 对象读取 \ No newline at end of file diff --git a/src/backend/app/schema/knowledge.py b/src/backend/app/schema/knowledge.py new file mode 100644 index 0000000..23baa9c --- /dev/null +++ b/src/backend/app/schema/knowledge.py @@ -0,0 +1,135 @@ +""" +领域知识 Schema。 + +本模块定义了业务术语(领域知识)的创建、查询和响应模型。 +用于增强 AI 对特定行业术语的理解能力。 +""" + +# backend/app/schema/knowledge.py + +from pydantic import BaseModel, Field, ConfigDict, validator +from typing import List, Optional +from datetime import datetime + +# --- 基础模型 --- +class KnowledgeBase(BaseModel): + """ + 知识库基础 Schema。 + + Attributes: + term (str): 业务术语。 + definition (str): 术语定义。 + examples (Optional[str]): 使用示例。 + """ + term: str = Field(..., description="业务术语") + definition: str = Field(..., description="术语定义") + examples: Optional[str] = Field(None, description="使用示例") + + @validator('term') + def term_length(cls, v): + if not 1 <= len(v) <= 100: + raise ValueError('业务术语长度必须在1到100个字符之间') + return v + +# --- 3.4.1 创建/更新请求 --- +class KnowledgeCreate(KnowledgeBase): + """ + 创建术语请求 Schema。 + """ + pass + +# 更新术语请求 +class KnowledgeUpdate(KnowledgeBase): + """ + 更新术语请求 Schema。 + + Attributes: + term (Optional[str]): 业务术语。 + definition (Optional[str]): 术语定义。 + examples (Optional[str]): 使用示例。 + """ + term: Optional[str] = Field(None, description="业务术语") + definition: Optional[str] = None + examples: Optional[str] = None + + +# 批量删除请求 +class BulkDeleteRequest(BaseModel): + """ + 批量删除术语请求 Schema。 + + Attributes: + ids (List[int]): 要删除的知识条目ID列表。 + """ + ids: List[int] = Field(..., description="要删除的知识条目ID列表") + +# --- 3.4.1 / 3.4.2 响应对象 --- +class KnowledgeResponse(KnowledgeBase): + """ + 术语响应 Schema。 + + Attributes: + knowledge_id (int): 知识条目ID。 + project_id (int): 所属项目ID。 + created_at (datetime): 创建时间。 + """ + knowledge_id: int + project_id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + +# --- 3.4.2 分页列表响应 --- +class PaginatedKnowledgeList(BaseModel): + """ + 术语分页列表响应 Schema。 + + Attributes: + total (int): 总记录数。 + page (int): 当前页码。 + page_size (int): 每页数量。 + items (List[KnowledgeResponse]): 术语列表。 + """ + total: int + page: int + page_size: int + items: List[KnowledgeResponse] + + model_config = ConfigDict(from_attributes=True) + +# --- 3.4.3 导入结果响应 --- +class ImportFailure(BaseModel): + """ + 导入失败记录 Schema。 + + Attributes: + row (int): 失败行号。 + error (str): 错误信息。 + """ + row: int + error: str + +class ImportResponse(BaseModel): + """ + 导入结果响应 Schema。 + + Attributes: + imported_count (int): 成功导入数量。 + failed_count (int): 失败数量。 + failures (List[ImportFailure]): 失败记录列表。 + """ + imported_count: int + failed_count: int + failures: List[ImportFailure] + +# --- 3.4.4 导出结果响应 --- +class ExportResponse(BaseModel): + """ + 导出结果响应 Schema。 + + Attributes: + download_url (str): 下载链接。 + expires_at (datetime): 链接过期时间。 + """ + download_url: str + expires_at: datetime \ No newline at end of file diff --git a/src/backend/app/schema/message.py b/src/backend/app/schema/message.py new file mode 100644 index 0000000..ab19e63 --- /dev/null +++ b/src/backend/app/schema/message.py @@ -0,0 +1,55 @@ +""" +消息记录 Schema。 + +本模块定义了历史消息记录的查询和展示模型。 +注意:实时聊天交互使用 `chat.py` 中的模型,此处主要用于历史记录回显。 +""" + +# backend/app/schema/message.py + +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + +# 消息的基础字段 +class MessageBase(BaseModel): + """ + 消息基础 Schema。 + + Attributes: + message_type (str): 消息类型 (user, assistant, system)。 + content (str): 消息内容。 + requires_confirmation (bool): 是否需要用户确认。 + user_confirmed (Optional[bool]): 用户确认状态。 + """ + message_type: str = Field(..., description="消息类型: user, assistant, system") + content: str = Field(..., description="消息内容") + requires_confirmation: bool = Field(False, description="是否需要用户确认") + user_confirmed: Optional[bool] = Field(None, description="用户确认状态") + +# 创建消息时的参数 +class MessageCreate(MessageBase): + """ + 创建消息请求 Schema。 + + Attributes: + session_id (int): 所属会话 ID。 + """ + session_id: int = Field(..., description="所属会话ID") + +# API 返回的完整消息模型 +class MessageResponse(MessageBase): + """ + 消息响应 Schema。 + + Attributes: + message_id (int): 消息 ID。 + session_id (int): 所属会话 ID。 + created_at (datetime): 创建时间。 + """ + message_id: int + session_id: int + created_at: datetime + + class Config: + from_attributes = True \ No newline at end of file diff --git a/src/backend/app/schema/project.py b/src/backend/app/schema/project.py new file mode 100644 index 0000000..0ad47cd --- /dev/null +++ b/src/backend/app/schema/project.py @@ -0,0 +1,270 @@ +""" +项目管理 Schema。 + +本模块定义了项目的创建、配置更新、数据库连接信息及状态流转相关的模型。 +""" + +# backend/app/schema/project.py + +from pydantic import BaseModel, Field, ConfigDict, validator +from typing import Optional, Literal, List, Any,Dict +from datetime import datetime +from enum import Enum + + +# --- 1. 枚举定义 --- +class ProjectStatusEnum(str, Enum): + INITIALIZING = "initializing" # 正在生成或等待确认 + PENDING_CONFIRMATION = "pending_confirmation" # (新增建议) 生成完毕,等待用户确认 + ACTIVE = "active" # 已部署 + DELETED = "deleted" + + +class CreationStageEnum(str, Enum): + """3.2.2. 创建进度阶段""" + INITIALIZING = "initializing" + GENERATING_SCHEMA = "generating_schema" # 正在生成 Schema + SCHEMA_GENERATED = "schema_generated" # Schema 生成完毕,等待用户确认 + GENERATING_DDL = "generating_ddl" # 正在生成 DDL + DDL_GENERATED = "ddl_generated" # DDL 生成完毕,等待用户部署 + EXECUTING_DDL = "executing_ddl" # 正在部署 + COMPLETED = "completed" # 完成 + + +# --- 2. 请求 DTOs --- + +class ProjectCreate(BaseModel): + """ + 创建项目请求体。 + + Attributes: + project_name (str): 项目名称。 + db_type (Literal): 数据库类型(mysql、postgresql、sqlite)。 + description (str): 项目描述。 + ai_model (Literal): 用于生成Schema的AI模型。 + """ + project_name: str = Field(..., description="项目名称") + db_type: Literal['mysql', 'postgresql', 'sqlite'] = Field(..., description="数据库类型") + description: str = Field(..., description="项目描述") + ai_model: Literal["gpt4", "deepseek", "chatgpt"] = Field("gpt4",description="用于生成Schema的AI模型") + + @validator('project_name') + def project_name_length(cls, v): + if not 1 <= len(v) <= 50: + raise ValueError('项目名称长度必须在1到50个字符之间') + return v + + @validator('description') + def description_length(cls, v): + if len(v) > 1000: + raise ValueError('项目描述长度不能超过1000个字符') + return v + + +class ProjectUpdate(BaseModel): + """ + 更新项目请求体(PATCH)。 + + Attributes: + project_name (Optional[str]): 项目名称。 + description (Optional[str]): 项目描述。 + schema_definition (Optional[Dict[str, Any]]): 前端修改后的DDL和Schema结构。 + """ + project_name: Optional[str] = Field(None, description="项目名称") + description: Optional[str] = Field(None, description="项目描述") + # ========================================================= + # 允许前端回传修改后的 Schema/DDL 进行保存 + # ========================================================= + schema_definition: Optional[Dict[str, Any]] = Field( + None, + description="前端修改后的DDL和Schema结构 {'ddl': '...', 'schema': '...'}" + ) + ddl_statement: Optional[str] = None + + @validator('project_name') + def project_name_length(cls, v): + if v is not None and not 1 <= len(v) <= 50: + raise ValueError('项目名称长度必须在1到50个字符之间') + return v + + @validator('description') + def description_length(cls, v): + if v is not None and len(v) > 500: + raise ValueError('项目描述长度不能超过500个字符') + return v + + +class GenerateDDLRequest(BaseModel): + """ + 用户确认 Schema 后,请求生成 DDL 的参数。 + """ + confirmed_schema: str = Field(..., description="用户确认或修改后的 Schema 内容") + # 如果用户在确认 Schema 阶段同时也微调了需求,可以传此参数更新项目描述,否则使用原描述 + requirements: Optional[str] = Field(None, description="可选:修正后的需求描述") + + +class RegenerateERRequest(BaseModel): + """ + 重新生成 ER 图的请求体。 + """ + schema_text: str = Field(..., description="Schema 内容") + ai_model: Literal["gpt4", "deepseek", "chatgpt", "qwen"] = Field("gpt4", description="AI 模型标识") + + +# --- :部署请求 DTO --- +class ProjectDeployRequest(BaseModel): + """ + 用户确认并提交部署的请求体。 + + Attributes: + confirmed_ddl (str): 用户确认后的最终 DDL 语句。 + confirmed_schema (Optional[str]): 对应的 Schema 描述。 + use_smart_parse (bool): 是否使用后端的方言转换和拓扑排序。 + """ + confirmed_ddl: str = Field(..., description="用户确认后的最终 DDL 语句") + confirmed_schema: Optional[str] = Field(None, description="对应的 Schema 描述") + # --- 预留接口:控制是否使用后端的高级解析功能 --- + use_smart_parse: bool = Field( + False, + description="是否使用后端的方言转换和拓扑排序。默认为True。未来如果AI生成的DDL足够完美,可设为False直接执行。" + ) + + +class DeleteConfirmationRequest(BaseModel): + """3.2.5 确认删除请求""" + confirmation_text: str = Field(..., description="必须输入 DELETE") + + @validator('confirmation_text') + def confirmation_text_must_be_delete(cls, v): + if v != 'DELETE': + raise ValueError('必须输入 DELETE 以确认删除') + return v + + +# --- 3. 响应 DTOs --- + +class ProjectAsyncResponse(BaseModel): + """ + 异步创建项目的响应体 (202 Accepted)。 + + Attributes: + project_id (int): 项目 ID。 + project_name (str): 项目名称。 + project_status (ProjectStatusEnum): 项目状态。 + message (str): 响应消息。 + task_id (Optional[str]): Celery 任务 ID,用于查询任务状态。 + """ + project_id: int + project_name: str + # 使用 alias="status" 匹配前端期望的 {"status": "..."} + project_status: ProjectStatusEnum = Field(..., alias="status") + message: str = "项目创建请求已受理,正在初始化..." + task_id: Optional[str] = Field(None, description="Celery 任务 ID,用于查询任务状态") + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class ProjectListOne(BaseModel): + """ + 项目列表单项。 + + Attributes: + project_id (int): 项目 ID。 + project_name (str): 项目名称。 + description (Optional[str]): 项目描述。 + project_status (ProjectStatusEnum): 项目状态。 + updated_at (datetime): 更新时间。 + db_type (str): 数据库类型。 + """ + project_id: int + project_name: str + description: Optional[str] = None + project_status: ProjectStatusEnum + updated_at: datetime + db_type: str + model_config = ConfigDict(from_attributes=True) + + +class PaginatedProjectList(BaseModel): + """ + 项目分页列表包装器。 + + Attributes: + total (int): 项目总数。 + page (int): 当前页码。 + page_size (int): 每页数量。 + items (List[ProjectListOne]): 项目列表。 + """ + total: int + page: int + page_size: int + items: List[ProjectListOne] + + model_config = ConfigDict(from_attributes=True) + + +class ProjectDetailOut(ProjectListOne): + """ + 项目详情 DTO。 + + Attributes: + created_at (datetime): 创建时间。 + creation_stage (Optional[CreationStageEnum]): 创建进度阶段。 + schema_definition (Optional[Dict[str, Any]]): AI生成的包含 'schema' 和 'ddl' 的JSON对象。 + """ + created_at: datetime + creation_stage: Optional[CreationStageEnum] = CreationStageEnum.INITIALIZING + # 可以添加 analysis_result, ddl_result 等字段 + # ========================================================= + # 将数据库中的 JSONB 字段返回给前端 + # ========================================================= + schema_definition: Optional[Dict[str, Any]] = Field( + None, + description="AI生成的包含 'schema' 和 'ddl' 的JSON对象" + ) + ddl_statement: Optional[str] = Field(None, description="DDL 语句文本") + er_diagram_code: Optional[str] = Field(None, description="Mermaid ER图代码") + model_config = ConfigDict(from_attributes=True) + + +# Removed ProjectResponse wrapper to avoid unnecessary nesting +# ProjectDetailOut is used directly in API responses + + +class ConfirmationTokenResponse(BaseModel): + """ + 删除令牌响应体。 + + Attributes: + confirmation_token (str): 删除令牌。 + expires_at (datetime): 令牌过期时间。 + """ + confirmation_token: str + expires_at: datetime + + +class TaskStatusResponse(BaseModel): + """ + Celery 任务状态响应体。 + + Attributes: + task_id (str): 任务 ID。 + task_status (str): Celery 任务状态 (PENDING, STARTED, SUCCESS, FAILURE, RETRY)。 + ready (bool): 任务是否完成。 + successful (Optional[bool]): 任务是否成功(仅当 ready=True 时有值)。 + result (Optional[Dict[str, Any]]): 任务结果(仅当成功时有值)。 + error (Optional[str]): 错误信息(仅当失败时有值)。 + project_id (Optional[int]): 项目 ID。 + creation_stage (Optional[CreationStageEnum]): 项目创建阶段(业务状态)。 + project_status (Optional[str]): 项目状态。 + """ + task_id: str + task_status: str = Field(..., description="Celery 任务状态: PENDING, STARTED, SUCCESS, FAILURE, RETRY") + ready: bool = Field(..., description="任务是否完成") + successful: Optional[bool] = Field(None, description="任务是否成功(仅当 ready=True 时有值)") + result: Optional[Dict[str, Any]] = Field(None, description="任务结果") + error: Optional[str] = Field(None, description="错误信息") + # 项目业务状态 + project_id: Optional[int] = Field(None, description="项目 ID") + creation_stage: Optional[CreationStageEnum] = Field(None, description="项目创建阶段(业务状态)") + project_status: Optional[str] = Field(None, description="项目状态") \ No newline at end of file diff --git a/src/backend/app/schema/query.py b/src/backend/app/schema/query.py new file mode 100644 index 0000000..b5cc957 --- /dev/null +++ b/src/backend/app/schema/query.py @@ -0,0 +1,47 @@ +""" +查询分析 Schema。 + +本模块定义了用于自然语言查询、SQL 执行结果展示及图表推荐的数据模型。 +""" + +# backend/app/schema/query.py + +from pydantic import BaseModel, Field, validator +from typing import Literal +from datetime import datetime + +# 创建会话 +class SessionCreate(BaseModel): + """ + 创建会话请求 Schema。 + + Attributes: + session_name (str): 会话名称 (默认 "New Session")。 + """ + session_name: str = Field("New Session", description="会话名称") + + @validator('session_name') + def session_name_length(cls, v): + if len(v) > 50: + raise ValueError('会话名称长度不能超过50个字符') + return v + +# 会话信息 +class SessionListOne(BaseModel): + """ + 会话列表单项 Schema。 + + Attributes: + session_id (int): 会话 ID。 + session_name (str): 会话名称。 + project_id (int): 项目 ID。 + created_at (datetime): 创建时间。 + updated_at (datetime): 更新时间。 + """ + session_id: int + session_name: str + project_id: int + created_at: datetime + updated_at: datetime + +# \ No newline at end of file diff --git a/src/backend/app/schema/report.py b/src/backend/app/schema/report.py new file mode 100644 index 0000000..ffb5d32 --- /dev/null +++ b/src/backend/app/schema/report.py @@ -0,0 +1,120 @@ +""" +报表配置 Schema。 + +本模块定义了分析报表的保存、更新和展示配置模型。 +""" + +# backend/app/schema/report.py + +from pydantic import BaseModel, ConfigDict +from typing import List, Optional, Any, Dict, Literal +from datetime import datetime + +# 图表配置 +class ChartConfig(BaseModel): + """ + 图表配置 Schema。 + + Attributes: + x_axis_key (str): X轴对应的键名。 + y_axis_key (str): Y轴对应的键名。 + """ + x_axis_key: str + y_axis_key: str + +# 1. 创建报表请求 +class ReportCreate(BaseModel): + """ + 创建报表请求 Schema。 + + Attributes: + report_name (str): 报表名称。 + query_id (int): 关联的查询结果 ID。 + chart_type (str): 图表类型 (默认 "table")。 + description (Optional[str]): 报表描述。 + chart_config (Optional[ChartConfig]): 图表配置。 + """ + report_name: str + query_id: int # 关联的 result_id + chart_type: str = "table" + description: Optional[str] = None + chart_config: Optional[ChartConfig] = None + +# 2. 修改报表请求 +class ReportUpdate(BaseModel): + """ + 更新报表请求 Schema。 + + Attributes: + report_name (Optional[str]): 报表名称。 + chart_type (Optional[str]): 图表类型。 + description (Optional[str]): 报表描述。 + chart_config (Optional[ChartConfig]): 图表配置。 + """ + report_name: Optional[str] = None + chart_type: Optional[str] = None + description: Optional[str] = None + chart_config: Optional[ChartConfig] = None + +# 3. 报表响应 (调整为从 AnalysisReport 获取元数据,从 QueryResult 获取 Data) +class Report(BaseModel): + """ + 报表响应 Schema。 + + Attributes: + id (str): 报表 ID。 + project_id (str): 项目 ID。 + name (str): 报表名称。 + type (str): 图表类型。 + description (Optional[str]): 报表描述。 + data (List[Dict[str, Any]]): 报表数据 (来自 QueryResult)。 + source_query_text (Optional[str]): 来源 SQL 语句。 + chart_config (Optional[ChartConfig]): 图表配置。 + updated_at (str): 更新时间。 + """ + id: str # report_id + project_id: str + name: str # 用户自定义的名称 + type: str # 用户保存的 chart_type + description: Optional[str] + + # 以下数据来自关联的 QueryResult + data: List[Dict[str, Any]] + source_query_id: Optional[str] = None + source_query_text: Optional[str] + + chart_config: Optional[ChartConfig] + updated_at: str + + model_config = ConfigDict(from_attributes=True) + +class HistoryQueryField(BaseModel): + name: str + type: Literal['string', 'number', 'date', 'bool', 'object'] + + +class HistoryQueryResult(BaseModel): + columns: List[str] + fields: List[HistoryQueryField] + data: List[Dict[str, Any]] + + +# 4. 历史查询 +class HistoryQuery(BaseModel): + """ + 历史查询记录 Schema。 + + Attributes: + id (str): 记录 ID。 + project_id (str): 项目 ID。 + query_text (str): 查询文本。 + timestamp (str): 时间戳。 + result (Optional[HistoryQueryResult]): 查询结果(包含 data/columns/fields)。 + """ + id: str + project_id: str + query_text: str + timestamp: str + result: Optional[HistoryQueryResult] = None + reportable: bool = True + unreportable_reason: Optional[str] = None \ No newline at end of file diff --git a/src/backend/app/schema/session.py b/src/backend/app/schema/session.py new file mode 100644 index 0000000..5554092 --- /dev/null +++ b/src/backend/app/schema/session.py @@ -0,0 +1,69 @@ +""" +会话管理 Schema。 + +本模块定义了聊天会话的创建、重命名、查询及响应模型。 +""" + +# backend/app/schema/session.py + +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, validator, ConfigDict + +# 基础 Schema,包含共享字段 +class SessionBase(BaseModel): + """ + 会话基础 Schema。 + + Attributes: + session_name (Optional[str]): 会话名称。 + """ + session_name: Optional[str] = "New Session" # 会话名称 + +# 创建会话时的请求模型 +class SessionCreate(SessionBase): + """ + 创建会话请求 Schema。 + + Attributes: + project_id (int): 关联的项目 ID。 + """ + project_id: int + + @validator('project_id') + def project_id_positive(cls, v): + if v <= 0: + raise ValueError('项目ID必须大于0') + return v + +# 更新会话时的请求模型 +class SessionUpdate(SessionBase): + """ + 更新会话请求 Schema。 + + Attributes: + session_name (Optional[str]): 会话名称。 + """ + session_id: Optional[int] = None + session_name: Optional[str] = None + # last_activity 通常由后端自动维护,不通过 API 直接修改 + +# API 返回的响应模型 +class SessionResponse(SessionBase): + """ + 会话响应 Schema。 + + Attributes: + session_id (int): 会话 ID。 + project_id (int): 关联的项目 ID。 + current_model (Optional[str]): 当前会话使用的模型。 + created_at (datetime): 创建时间。 + last_activity (Optional[datetime]): 最后活动时间。 + """ + session_id: int + project_id: int + current_model: Optional[str] = None + created_at: datetime + last_activity: Optional[datetime] + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/src/backend/app/schema/token.py b/src/backend/app/schema/token.py new file mode 100644 index 0000000..5dc331e --- /dev/null +++ b/src/backend/app/schema/token.py @@ -0,0 +1,25 @@ +""" +令牌 Schema。 + +本模块定义了 JWT 访问令牌 (Access Token) 的响应结构。 +""" + +# backend/app/schema/token.py + +from typing import Optional +from pydantic import BaseModel + +class Token(BaseModel): + """ + 返回给前端的 Token 响应体 + """ + access_token: str + token_type: str + +class TokenPayload(BaseModel): + """ + JWT 解析后的载荷数据 + """ + # sub (Subject) 是 JWT 标准字段,通常用来存用户 ID + # 你的用户 ID 是 int 类型,所以这里定义为 Optional[int] + sub: Optional[int] = None \ No newline at end of file diff --git a/src/backend/app/schema/unified_response.py b/src/backend/app/schema/unified_response.py new file mode 100644 index 0000000..a25dcbf --- /dev/null +++ b/src/backend/app/schema/unified_response.py @@ -0,0 +1,110 @@ +""" +统一响应 Schema。 + +本模块定义了 API 接口的标准响应格式,采用 "Always 200" 策略。 +无论业务成功还是失败,HTTP 状态码始终返回 200 OK。 +前端通过 Body 内的 code 字段来判断业务是否成功。 +""" + +# backend/app/schema/unified_response.py + +from datetime import datetime +from pydantic import BaseModel, Field +from typing import Generic, TypeVar, Optional, Literal, Dict, Any + +# 基础配置:泛型类型变量 +# 通用数据类型变量(用于响应的数据字段) +T = TypeVar("T") + +class UnifiedResponse(BaseModel, Generic[T]): + """统一响应模型(Always 200 策略) + + Attributes: + code: 业务状态码。0 代表成功,1xxxx 代表参数错误,2xxxx 代表业务逻辑阻断等。 + message: 展示给用户的提示信息。 + data: 业务数据。 + """ + code: int = Field(0, description="业务状态码。0 代表成功,1xxxx 代表参数错误,2xxxx 代表业务逻辑阻断等") + message: str = Field("操作成功", description="展示给用户的提示信息") + data: Optional[T] = Field(None, description="业务数据") + + @classmethod + def success( + cls, + data: Optional[T] = None, + message: str = "操作成功" + ) -> "UnifiedResponse[T]": + """快捷创建成功响应""" + return cls(code=0, message=message, data=data) + + @classmethod + def error( + cls, + code: int, + message: str, + data: Optional[T] = None + ) -> "UnifiedResponse[T]": + """快捷创建错误响应""" + return cls(code=code, message=message, data=data) + +# 分页响应数据模型(用于嵌套在 UnifiedResponse 的 data 字段中) +class PageData(BaseModel, Generic[T]): + """分页数据模型 + + Attributes: + total: 符合条件的总记录数 + page: 当前页码(从 1 开始) + page_size: 每页显示的记录数 + items: 当前页的记录列表 + """ + total: int = Field(..., description="符合条件的总记录数") + page: int = Field(..., description="当前页码(从 1 开始)") + page_size: int = Field(..., description="每页显示的记录数") + items: Optional[T] = Field(None, description="当前页的记录列表") + + @classmethod + def create( + cls, + total: int, + page: int, + page_size: int, + items: Optional[T] = None + ) -> "PageData[T]": + """快捷创建分页数据""" + return cls(total=total, page=page, page_size=page_size, items=items) + +# 专用数据模型(各业务场景的具体数据结构) +class LoginData(BaseModel): + """登录成功专用数据模型""" + access_token: str = Field(..., description="JWT 访问令牌") + token_type: str = Field("bearer", description="令牌类型(固定为 bearer)") + user: Dict[str, Any] = Field(..., description="用户基础信息(建议后续替换为 UserInfo 模型,字段更明确)") + +class LoginHistoryData(BaseModel): + """登录历史分页记录数据模型""" + login_id: int = Field(..., description="登录记录 ID") + login_time: str = Field(..., description="登录时间(格式:YYYY-MM-DD HH:MM:SS)") + logout_time: str = Field(..., description="登出时间(未登出则为 null 或 空字符串)") + ip_address: str = Field(..., description="登录 IP 地址") + device_info: str = Field(..., description="登录设备信息(如浏览器、手机型号)") + login_status: str = Field(..., description="登录状态(如 success/failed/online)") + +class ProjectListData(BaseModel): + """项目列表分页记录数据模型""" + project_id: int = Field(..., description="项目 ID") + project_name: str = Field(..., description="项目名称") + description: str = Field(..., description="项目描述") + project_status: Literal['initializing', 'activate', 'deleted'] = Field(..., description="项目状态:初始化/激活/已删除") + updated_at: datetime = Field(..., description="最后更新时间(UTC 时间或本地时间,建议统一格式)") + +class SessionListData(BaseModel): + """会话列表分页记录数据模型""" + session_id: int = Field(..., description="会话 ID") + session_name: str = Field(..., description="会话名称") + last_activity: datetime = Field(..., description="最后活动时间(UTC 时间或本地时间,建议统一格式)") + +# 简化类型别名(减少路由层泛型冗余写法) +LoginSuccessResponse = UnifiedResponse[LoginData] +LoginHistoryPageResponse = UnifiedResponse[PageData[list[LoginHistoryData]]] +ProjectListPageResponse = UnifiedResponse[PageData[list[ProjectListData]]] +SessionListPageResponse = UnifiedResponse[PageData[list[SessionListData]]] \ No newline at end of file diff --git a/src/backend/app/schema/user.py b/src/backend/app/schema/user.py new file mode 100644 index 0000000..fed9c6d --- /dev/null +++ b/src/backend/app/schema/user.py @@ -0,0 +1,244 @@ +""" +用户管理 Schema。 + +本模块定义了用户个人信息的查询、更新、修改密码及头像等操作的请求和响应模型。 +""" + +from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator, ConfigDict, validator +from typing import Optional, Literal, List, Any +from datetime import datetime +import re + + +# 基础用户信息 +class UserBase(BaseModel): + """ + 用户基础信息 Schema。 + + Attributes: + username (str): 用户名。 + email (str): 邮箱。 + """ + username: str = Field(..., description="用户名") + email: str + + @validator('username') + def username_length(cls, v): + if not 3 <= len(v) <= 50: + raise ValueError('用户名长度必须在3到50个字符之间') + return v + + @validator('email') + def validate_email(cls, v): + try: + EmailStr.validate(v) + except Exception: + raise ValueError('邮箱格式不正确,请输入有效的邮箱地址') + return v + +# 当前用户响应 +class UserMe(BaseModel): + """ + 当前登录用户详情 Schema。 + + Attributes: + user_id (int): 用户 ID。 + username (str): 用户名。 + email (str): 邮箱。 + status (str): 用户状态。 + used_databases (int): 已使用的数据库项目数。 + max_databases (int): 最大数据库额度。 + avatar_url (Optional[str]): 头像 URL。 + is_admin (bool): 是否管理员。 + last_login_at (Optional[datetime]): 最后登录时间。 + created_at (datetime): 注册时间。 + """ + user_id: int + username: str + email: str + status: Literal['normal', 'suspended', 'banned'] + # 字段名必须与数据库一致 (used_databases) + used_databases: int + max_databases: int + avatar_url: Optional[str] = None + is_admin: bool + # 字段名必须与数据库一致 (last_login_at) + last_login_at: Optional[datetime] = None + created_at: datetime + # 必须包含此配置,且缩进要在 class 内部! + model_config = ConfigDict(from_attributes=True) + +# 更新用户名 +class UserUpdateUsername(BaseModel): + """ + 更新用户名请求 Schema。 + + Attributes: + username (str): 新用户名。 + """ + username: str = Field(..., description="用户名") + + @validator('username') + def username_length(cls, v): + if not 3 <= len(v) <= 50: + raise ValueError('用户名长度必须在3到50个字符之间') + return v + +# 更新用户邮箱请求 +class UserUpdateEmailRequest(BaseModel): + """ + 更新邮箱请求 Schema。 + + Attributes: + new_email (str): 新邮箱地址。 + """ + new_email: str # 修改为 str 类型 + + @validator('new_email') + def validate_new_email(cls, v): + try: + EmailStr.validate(v) + except Exception: + raise ValueError('邮箱格式不正确,请输入有效的邮箱地址') + return v + +# 更新用户邮箱确认 +class UserUpdateEmailConfirm(BaseModel): + """ + 更新邮箱确认 Schema。 + + Attributes: + new_email (str): 新邮箱地址。 + code (str): 验证码。 + """ + new_email: str + code: str = Field(..., description="验证码") + + @validator('code') + def code_length(cls, v): + if not 6 <= len(v) <= 6: + raise ValueError('验证码必须为6个字符') + return v + + @validator('new_email') + def validate_new_email(cls, v): + try: + EmailStr.validate(v) + except Exception: + raise ValueError('邮箱格式不正确,请输入有效的邮箱地址') + return v + +# 更新头像 +class UserUpdateAvatar(BaseModel): + """ + 更新头像请求 Schema。 + + Attributes: + avatar_url (Optional[str]): 头像 URL。 + """ + avatar_url: Optional[str] = None + + @field_validator('avatar_url') + @classmethod + def validate_avatar_url(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + + value = v.strip() + if value == '': + return None + + # 1) Base64 Data URL:data:image/png;base64,... + if value.lower().startswith('data:'): + match = re.match(r'^data:(?P[^;]+);base64,', value, flags=re.IGNORECASE) + if not match: + raise ValueError('头像数据格式不正确') + mime = match.group('mime').lower() + if mime not in {'image/png', 'image/jpeg', 'image/gif'}: + raise ValueError('头像只支持 PNG、JPG、GIF 格式') + return value + + # 2) 普通 URL:按后缀名限制(防止明显的非图片地址) + lower = value.lower() + if not re.search(r'\.(png|jpe?g|gif)(\?.*)?$', lower): + raise ValueError('头像只支持 PNG、JPG、GIF 格式') + return value + +# 更新密码 +class UserUpdatePassword(BaseModel): + """ + 更新密码请求 Schema。 + + Attributes: + old_password (str): 旧密码。 + new_password (str): 新密码。 + confirm_password (str): 确认密码。 + """ + old_password: str = Field(..., description="旧密码") + new_password: str = Field(..., description="新密码") + confirm_password: str = Field(..., description="确认密码") + + @model_validator(mode='after') + def check_password_length(self) -> 'UserUpdatePassword': + for pwd in [self.old_password, self.new_password, self.confirm_password]: + if not 6 <= len(pwd) <= 50: + raise ValueError('密码长度必须在6到50个字符之间') + return self + + @model_validator(mode='before') + @classmethod + def passwords_match(cls, data: Any) -> Any: + # Pydantic V2 的验证逻辑必须是 @model_validator 或 @field_validator + if isinstance(data, dict): + new_password = data.get('new_password') + confirm_password = data.get('confirm_password') + + if new_password and confirm_password and new_password != confirm_password: + raise ValueError('两次输入的密码不一致') + return data + + + model_config = ConfigDict(from_attributes=True) + + +# --- 1. 登录历史单项 DTO (用于 items 列表) --- +class LoginHistoryItem(BaseModel): + """ + 登录历史列表单项 Schema。 + + Attributes: + login_id (int): 登录记录 ID。 + login_time (datetime): 登录时间。 + logout_time (Optional[datetime]): 登出时间。 + ip_address (str): IP 地址。 + user_agent (Optional[str]): 设备信息。 + login_status (str): 登录状态。 + """ + login_id: int = Field(..., description="登录记录ID") + login_time: datetime + logout_time: Optional[datetime] = None + ip_address: str + # 假设设备信息映射到 user_agent + user_agent: Optional[str] = Field(None, description="设备信息/User Agent") + login_status: Literal['success', 'failed', 'expired', 'forced_logout'] + + #实际 DB 中有 session_duration 和 failure_reason,可以按需添加 + + model_config = ConfigDict(from_attributes=True) + + +# ---------------------------------------------------- +# 2. 分页包装器 DTO (用于接口响应) +# ---------------------------------------------------- +class PaginatedLoginHistory(BaseModel): + """ + 登录历史分页列表响应 DTO (Doc 3.1.7) + """ + total: int = Field(..., description="总记录数") + page: int = Field(1, description="当前页码") + page_size: int = Field(10, description="每页记录数") # Doc 要求默认 10 + + # 列表项使用 LoginHistoryItem + items: List['LoginHistoryItem'] + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/src/backend/app/server.py b/src/backend/app/server.py new file mode 100644 index 0000000..e923e15 --- /dev/null +++ b/src/backend/app/server.py @@ -0,0 +1,170 @@ +# backend/app/server.py + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError +from sqlalchemy.exc import SQLAlchemyError +# 导入第三方库异常类 +import redis.exceptions as redis_exceptions +from jose import JWTError +import httpx +import aiohttp +import celery.exceptions as celery_exceptions + +from core.config import config +from core.log import log +from core.database import PsqlHelper +from core.exceptions import BusinessException, AppException +from core.exception_handlers import ( + business_exception_handler, + app_exception_handler, + validation_exception_handler, + pydantic_validation_exception_handler, + sqlalchemy_exception_handler, + general_exception_handler, + io_exception_handler, + timeout_exception_handler, + type_exception_handler, + value_exception_handler, + key_exception_handler, + jwt_exception_handler, +) + +import models +from mysql.mysql_database import MysqlHelper +from postgresql.postgres_database import PostgresHelper +from sqlite.sqlite_database import SQLiteHelper +from redis_client.redis import init_redis, close_redis, get_redis, init_redis_listener, close_redis_listener +from redis_client.expiration_listener import redis_expire_listener +from api.v1.api import api_router +import asyncio +import os + +async def startup_services(app: FastAPI): + """ + 初始化服务 + + :param app: FastAPI应用程序实例 + """ + + # 初始化config + log.info("initialize config") + app.state.config = config + + # 初始化数据库连接 + log.info("initialize database linking") + app.state.psql_engine = await PsqlHelper.init_conn_psql(app.state.config.db) + + # 初始化Mysql连接 + await MysqlHelper.init_root_engine(config.mysql) + + # 测试Mysql连接 + await MysqlHelper.test_connection() + + # 初始化 PostgreSQL Root 连接 (用于用户项目) + log.info("initialize postgresql root engine") + await PostgresHelper.init_root_engine(config.postgresql) + try: + await PostgresHelper.test_connection() + log.info("PostgreSQL connection test passed") + except Exception as e: + log.error(f"PostgreSQL connection test failed: {e}") + + # 初始化Redis连接 + log.info("initialize redis_client linking") + await init_redis() + redis = get_redis() + if redis : + app.state.redis = get_redis() + + # 初始化Redis监听连接并启动监听器 + log.info("initialize redis_client listener") + await init_redis_listener() + # 在后台启动Redis过期事件监听器 + asyncio.create_task(redis_expire_listener()) + +async def close_services(app: FastAPI): + """ + 关闭服务 + + :param app: FastAPI应用程序实例 + """ + + # 关闭数据库连接 + log.info("close database linking") + await PsqlHelper.close_conn_psql(app.state.psql_engine) + # 关闭 PostgreSQL 连接池 (释放所有用户项目的连接) + await PostgresHelper.close_all_engine() + # 关闭 SQLite 连接池 (释放文件句柄) + await SQLiteHelper.close_all_engine() + # 关闭Redis连接 + await close_redis() + # 关闭Redis监听连接 + await close_redis_listener() + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + 应用程序生命周期管理器 + + :param app: FastAPI应用程序实例 + """ + + await startup_services(app) + log.info("app startup") + yield + log.info("app shutdown") + await close_services(app) + +def create_app() -> FastAPI: + """ + 创建FastAPI应用实例 + + Returns: + FastAPI: 配置好的FastAPI应用实例 + """ + app = FastAPI( + title=config.app.name, + description=config.app.description, + version=config.app.version, + lifespan=lifespan, + ) + + # 注册异常处理器 + app.add_exception_handler(BusinessException, business_exception_handler) + app.add_exception_handler(AppException, app_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(ValidationError, pydantic_validation_exception_handler) + app.add_exception_handler(SQLAlchemyError, sqlalchemy_exception_handler) + + # 注册Python内置异常处理器 + app.add_exception_handler(IOError, io_exception_handler) + app.add_exception_handler(FileNotFoundError, io_exception_handler) + app.add_exception_handler(TimeoutError, timeout_exception_handler) + app.add_exception_handler(TypeError, type_exception_handler) + app.add_exception_handler(ValueError, value_exception_handler) + app.add_exception_handler(KeyError, key_exception_handler) + app.add_exception_handler(AttributeError, key_exception_handler) + + # 注册第三方库异常处理器 + app.add_exception_handler(JWTError, jwt_exception_handler) + + # 兜底异常处理器 + app.add_exception_handler(Exception, general_exception_handler) + + # 挂载静态目录 + static_dir = "static" + if not os.path.exists(static_dir): + os.makedirs(static_dir) + app.mount("/static", StaticFiles(directory=static_dir), name="static") + + # 注册API路由 + app.include_router(api_router, prefix=config.app.api) + + return app + +# 创建应用实例 +my_app = create_app() \ No newline at end of file diff --git a/src/backend/app/service/__init__.py b/src/backend/app/service/__init__.py new file mode 100644 index 0000000..3fb29ab --- /dev/null +++ b/src/backend/app/service/__init__.py @@ -0,0 +1,7 @@ +""" +服务层初始化。 + +集中导出各模块的服务类与辅助函数,作为业务逻辑的核心入口。 +""" + +# backend/app/service/__init__.py diff --git a/src/backend/app/service/admin_service.py b/src/backend/app/service/admin_service.py new file mode 100644 index 0000000..6474f05 --- /dev/null +++ b/src/backend/app/service/admin_service.py @@ -0,0 +1,720 @@ +""" +管理员服务。 + +提供用户、公告与系统统计的管理能力,进行输入校验、调用 CRUD 层,并整形为 +管理端响应。 +""" + +# backend/app/service/admin_service.py + +from typing import List, Optional, Literal, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime, timezone, timedelta # 确保导入 datetime +from sqlalchemy import select, desc, and_ + +from core.log import log +from core.exceptions import ItemNotFoundException, ValidationException + +# 导入 DTO 和 CRUD +# 确保导入了所有具体的 Schema 类 +from schema.admin import ( + AdminUserListResponse, + AdminUserListItem, + AdminUpdateUserStatusRequest, + AdminUpdateUserStatusResponse, + AdminUpdateUserQuotaRequest, + AdminUpdateUserQuotaResponse, + AnnouncementCreateRequest, + AnnouncementResponse, + AnnouncementUpdateRequest, + AdminListResponse, + AdminListItem, + ViolationLogListResponse, + ViolationLogListItem, + AdminStatsResponse, + AdminUserDetailResponse, + AdminUserProjectItem, + AdminUserLoginHistoryItem +) +from crud.crud_user_account import crud_user_account +from crud.crud_announcement import crud_announcement +from crud.crud_admin_data import crud_admin_data +from crud.crud_project import crud_project +from crud.crud_user_login_history import crud_login_history + + +# ---------------------------------------------------------------------- +# 用户管理 +# ---------------------------------------------------------------------- + +async def get_admin_user_list_service( + db: AsyncSession, + page: int, + page_size: int, + search: Optional[str], + status: str, +) -> AdminUserListResponse: + """ + 获取管理员视角的用户列表。 + + Args: + db (AsyncSession): 数据库会话。 + page (int): 页码。 + page_size (int): 每页数量。 + search (Optional[str]): 搜索关键字。 + status (str): 用户状态过滤条件。 + + Returns: + AdminUserListResponse: 包含分页信息与用户条目的响应。 + """ + items_data, total = await crud_admin_data.get_user_list_with_stats(db, page, page_size, search, status) + items_dto = [AdminUserListItem(**data) for data in items_data] + return AdminUserListResponse(total=total, page=page, page_size=page_size, items=items_dto) + + +async def get_admin_user_detail_service( + db: AsyncSession, + user_id: int, + project_limit: int = 100, + login_limit: int = 20, +) -> AdminUserDetailResponse: + """获取管理员视角的用户详情(额度、已创建项目、登录历史)。""" + + user_obj = await crud_user_account.get(db, user_id) + if not user_obj: + raise ItemNotFoundException(f"User with ID {user_id} not found.") + + project_limit = max(0, min(project_limit, 500)) + login_limit = max(0, min(login_limit, 200)) + + project_count = await crud_project.get_total_count_by_user(db, user_id) + projects = await crud_project.get_by_user(db, user_id, skip=0, limit=project_limit) + + login_history_items, _total_login = await crud_login_history.get_multi_by_user( + db, user_id, skip=0, limit=login_limit + ) + + return AdminUserDetailResponse( + user_id=user_obj.user_id, + username=user_obj.username, + email=user_obj.email, + status=user_obj.status, + max_databases=user_obj.max_databases, + project_count=project_count, + last_login_at=user_obj.last_login_at, + created_at=getattr(user_obj, "created_at", None), + avatar_url=user_obj.avatar_url, + projects=[ + AdminUserProjectItem( + project_id=p.project_id, + project_name=p.project_name, + db_type=getattr(p, "db_type", None), + project_status=p.project_status, + created_at=p.created_at, + description=p.description, + ) + for p in projects + ], + login_history=[ + AdminUserLoginHistoryItem( + login_id=lh.login_id, + login_time=lh.login_time, + logout_time=lh.logout_time, + ip_address=lh.ip_address, + user_agent=lh.user_agent, + login_status=lh.login_status, + failure_reason=lh.failure_reason, + ) + for lh in login_history_items + ], + ) + + +# 在参数列表中添加 admin_user_id: int +async def update_user_status_service( + db: AsyncSession, + user_id: int, + data: AdminUpdateUserStatusRequest, + admin_user_id: int, +) -> AdminUpdateUserStatusResponse: + """ + 修改指定用户的状态。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 目标用户 ID。 + data (AdminUpdateUserStatusRequest): 状态更新请求体。 + admin_user_id (int): 执行操作的管理员用户 ID。 + + Returns: + AdminUpdateUserStatusResponse: 更新后的状态信息。 + + Raises: + ItemNotFoundException: 用户不存在。 + """ + # 1. 先查询用户是否存在 (获取 ORM 对象) + user_obj = await crud_user_account.get(db, user_id) + + if not user_obj: + raise ItemNotFoundException(f"User with ID {user_id} not found.") + + # 保存原始状态用于后续判断 + original_status = user_obj.status + + # 2. 业务逻辑:调用 CRUD 修改,传入 db_obj=user_obj + updated_user = await crud_user_account.update(db, db_obj=user_obj, status=data.status) + + # 3. 如果用户状态被修改,重置相关的风控计数 + from core.security import login_failure_tracker, freq_limiter, RemoteLoginDetector + + # 无论修改为什么状态,都清除 IP 变更历史 + await RemoteLoginDetector.clear_ip_history(user_id) + + if data.status == 'normal': + await login_failure_tracker.reset_failed_count(user_id) + await freq_limiter.reset_frequency(user_id) + log.info(f"管理员 {admin_user_id} 修改用户 {user_id} 状态为 normal,已重置登录失败计数、请求频率和 IP 历史") + elif data.status == 'banned': + from service.user_service import force_logout_user_sessions + await force_logout_user_sessions(user_id) + log.info(f"管理员 {admin_user_id} 修改用户 {user_id} 状态为 {data.status},已强制登出用户所有活跃会话并清除 IP 历史") + else: + log.info(f"管理员 {admin_user_id} 修改用户 {user_id} 状态为 {data.status},已清除 IP 历史") + + # 5. 业务逻辑:记录到 user_ban_log (占位,可后续实现) + # await create_ban_log(db, user_id, admin_user_id, reason=data.reason, ...) + + return AdminUpdateUserStatusResponse( + user_id=updated_user.user_id, + status=updated_user.status, + updated_at=datetime.now() + ) + + +async def update_user_quota_service( + db: AsyncSession, + user_id: int, + data: AdminUpdateUserQuotaRequest +) -> AdminUpdateUserQuotaResponse: + """ + 调整用户的资源额度。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 目标用户 ID。 + data (AdminUpdateUserQuotaRequest): 额度更新请求体。 + + Returns: + AdminUpdateUserQuotaResponse: 更新后的额度信息。 + + Raises: + ItemNotFoundException: 用户不存在。 + """ + # 1. 先获取对象 + user_obj = await crud_user_account.get(db, user_id) + + if not user_obj: + raise ItemNotFoundException(f"User with ID {user_id} not found.") + + # 2. 检查调整后的额度是否小于当前项目数 + project_count = await crud_project.get_total_count_by_user(db, user_id) + if data.max_databases < project_count: + raise ValidationException(f"调整后的额度({data.max_databases})不能小于当前项目数({project_count})") + + # 3. 传入对象进行更新 + updated_user = await crud_user_account.update(db, db_obj=user_obj, max_databases=data.max_databases) + + return AdminUpdateUserQuotaResponse( + user_id=updated_user.user_id, + max_databases=updated_user.max_databases, + updated_at=datetime.now() + ) + + +# ---------------------------------------------------------------------- +# 公告管理 +# ---------------------------------------------------------------------- + +async def create_announcement_service( + db: AsyncSession, + data: AnnouncementCreateRequest, + admin_user_id: int +) -> AnnouncementResponse: + """ + 创建新公告。 + + Args: + db (AsyncSession): 数据库会话。 + data (AnnouncementCreateRequest): 公告创建请求体。 + admin_user_id (int): 创建者管理员用户 ID。 + + Returns: + AnnouncementResponse: 创建后的公告信息。 + """ + announcement_orm = await crud_announcement.create(db, created_by=admin_user_id, **data.model_dump()) + return AnnouncementResponse.model_validate(announcement_orm) + + +async def update_announcement_service( + db: AsyncSession, + announcement_id: int, + data: AnnouncementUpdateRequest +) -> AnnouncementResponse: + """ + 更新公告内容或状态。 + + Args: + db (AsyncSession): 数据库会话。 + announcement_id (int): 公告 ID。 + data (AnnouncementUpdateRequest): 更新请求体。 + + Returns: + AnnouncementResponse: 更新后的公告信息。 + + Raises: + ItemNotFoundException: 公告不存在。 + """ + update_data = data.model_dump(exclude_unset=True) + announcement_orm = await crud_announcement.update(db, announcement_id=announcement_id, update_data=update_data) + if not announcement_orm: + raise ItemNotFoundException("Announcement not found.") + return AnnouncementResponse.model_validate(announcement_orm) + + +async def delete_announcement_service(db: AsyncSession, announcement_id: int) -> None: + """ + 删除公告。 + + Args: + db (AsyncSession): 数据库会话。 + announcement_id (int): 公告 ID。 + + Raises: + ItemNotFoundException: 公告不存在。 + """ + success = await crud_announcement.remove(db, announcement_id) + if not success: + raise ItemNotFoundException("Announcement not found.") + + +# ---------------------------------------------------------------------- +# 系统状态 +# ---------------------------------------------------------------------- + +async def get_admin_list_service(db: AsyncSession, page: int, page_size: int) -> AdminListResponse: + """ + 获取管理员列表。 + + Args: + db (AsyncSession): 数据库会话。 + page (int): 页码。 + page_size (int): 每页数量。 + + Returns: + AdminListResponse: 管理员分页列表。 + """ + admin_orms, total = await crud_admin_data.get_admin_list(db, page, page_size) + + # 手动构建 AdminListItem 对象,使用 Redis 中的在线状态 + items_dto = [] + for orm in admin_orms: + # 将 datetime 转换为 ISO 格式字符串 + last_login_str = None + if orm.last_login_at: + last_login_str = orm.last_login_at.isoformat() + + # 从 Redis 获取实时在线状态 + from service.user_service import service_get_user_online_status + is_online = await service_get_user_online_status(orm.user_id) + + item = AdminListItem( + user_id=orm.user_id, + username=orm.username, + email=orm.email, + avatar_url=orm.avatar_url, + last_login_at=last_login_str, + is_online=is_online, # 使用 Redis 中的实时在线状态 + ) + items_dto.append(item) + + return AdminListResponse(total=total, page=page, page_size=page_size, items=items_dto) + + +async def get_violation_logs_service( + db: AsyncSession, + page: int, + page_size: int, + risk_level: Optional[str], + resolution_status: Optional[str] +) -> ViolationLogListResponse: + """ + 获取违规记录列表。 + + Args: + db (AsyncSession): 数据库会话。 + page (int): 页码。 + page_size (int): 每页数量。 + risk_level (Optional[str]): 风险等级过滤。 + resolution_status (Optional[str]): 处置状态过滤。 + + Returns: + ViolationLogListResponse: 违规记录分页列表。 + """ + logs_data, total = await crud_admin_data.get_violation_logs(db, page, page_size, risk_level, resolution_status) + items_dto = [ViolationLogListItem(**data) for data in logs_data] + return ViolationLogListResponse(total=total, page=page, page_size=page_size, items=items_dto) + + +async def get_admin_stats_service(db: AsyncSession) -> AdminStatsResponse: + """ + 获取系统统计数据。 + + Args: + db (AsyncSession): 数据库会话。 + + Returns: + AdminStatsResponse: 统计看板数据。 + """ + stats_data = await crud_admin_data.get_system_stats(db) + return AdminStatsResponse(**stats_data) + + +# ============================================================================ +# 黑名单/频率限制管理功能 +# ============================================================================ + +async def ban_user_service( + db: AsyncSession, + user_id: int, + reason: str = "违反服务条款", + admin_id: int = 0 +) -> Dict[str, Any]: + """ + 管理员手动封禁用户。 + + Args: + db: 数据库会话 + user_id: 要封禁的用户ID + reason: 封禁原因 + admin_id: 执行封禁的管理员ID + + Returns: + 包含操作结果的字典 + """ + try: + from core.security import BlacklistManager + from models.user_account import UserAccount + + # 检查用户是否存在 + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == user_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise ItemNotFoundException(f"User with ID {user_id} not found") + + # 执行封禁 + success = await BlacklistManager.ban_user( + db, user_id, reason=reason, banned_by=admin_id + ) + + if success: + log.info(f"管理员 {admin_id} 手动封禁用户 {user_id}: {reason}") + return { + "success": True, + "message": f"用户 {user_id} 已被封禁", + "user_id": user_id, + "reason": reason, + "status": "banned", + } + else: + return { + "success": False, + "message": f"封禁用户 {user_id} 失败", + } + + except Exception as e: + log.error(f"封禁用户失败: {e}") + raise + + +async def unban_user_service( + db: AsyncSession, + user_id: int, + admin_id: int = 0 +) -> Dict[str, Any]: + """ + 管理员手动解封用户。 + + Args: + db: 数据库会话 + user_id: 要解封的用户ID + admin_id: 执行解封的管理员ID + + Returns: + 包含操作结果的字典 + """ + try: + from core.security import BlacklistManager, freq_limiter, login_failure_tracker + from models.user_account import UserAccount + + # 检查用户是否存在 + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == user_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise ItemNotFoundException(f"User with ID {user_id} not found") + + # 执行解封 + success = await BlacklistManager.unban_user(db, user_id, admin_id=admin_id) + + if success: + # 重置请求频率 + freq_limiter.reset_frequency(user_id) + # 重置登录失败计数(确保用户解封后可以正常登录) + await login_failure_tracker.reset_failed_count(user_id) + log.info(f"管理员 {admin_id} 手动解封用户 {user_id},已重置登录失败计数") + return { + "success": True, + "message": f"用户 {user_id} 已被解封", + "user_id": user_id, + "status": "normal", + } + else: + return { + "success": False, + "message": f"解封用户 {user_id} 失败", + } + + except Exception as e: + log.error(f"解封用户失败: {e}") + raise + + +async def get_banned_users_service( + db: AsyncSession, + page: int = 1, + page_size: int = 20 +) -> Dict[str, Any]: + """ + 获取被封禁的用户列表。 + + Args: + db: 数据库会话 + page: 页码 + page_size: 每页数量 + + Returns: + 包含被封禁用户列表的字典 + """ + try: + from models.user_account import UserAccount + from sqlalchemy import or_ + + # 获取总数(包括 banned 和 suspended 状态的用户) + count_result = await db.execute( + select(UserAccount).where( + or_( + UserAccount.status == 'banned', + UserAccount.status == 'suspended' + ) + ) + ) + total_count = len(count_result.scalars().all()) + + # 分页 + offset = (page - 1) * page_size + result = await db.execute( + select(UserAccount) + .where( + or_( + UserAccount.status == 'banned', + UserAccount.status == 'suspended' + ) + ) + .order_by(desc(UserAccount.updated_at)) + .offset(offset) + .limit(page_size) + ) + users = result.scalars().all() + + return { + "total": total_count, + "page": page, + "page_size": page_size, + "users": [ + { + "user_id": u.user_id, + "username": u.username, + "email": u.email, + "status": u.status, + "status_desc": "管理员封禁" if u.status == 'banned' else "系统标记异常", + "updated_at": u.updated_at.isoformat() if u.updated_at else None, + } + for u in users + ] + } + + except Exception as e: + log.error(f"获取被封禁用户列表失败: {e}") + raise + + +async def get_violation_stats_service( + db: AsyncSession, + hours: int = 24 +) -> Dict[str, Any]: + """ + 获取违规统计信息。 + + Args: + db: 数据库会话 + hours: 统计时间范围(小时) + + Returns: + 包含违规统计的字典 + """ + try: + from models.violation_log import ViolationLog + + cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) + + # 获取指定时间范围内的违规记录 + result = await db.execute( + select(ViolationLog).where( + ViolationLog.created_at >= cutoff_time + ) + ) + logs = result.scalars().all() + + # 统计各风险等级的数量 + risk_stats = { + "CRITICAL": 0, + "HIGH": 0, + "MEDIUM": 0, + "LOW": 0, + } + + event_type_stats = {} + + for log in logs: + risk_stats[log.risk_level] = risk_stats.get(log.risk_level, 0) + 1 + event_type_stats[log.event_type] = event_type_stats.get(log.event_type, 0) + 1 + + return { + "time_range_hours": hours, + "total_violations": len(logs), + "risk_level_distribution": risk_stats, + "event_type_distribution": event_type_stats, + "pending_violations": len([l for l in logs if l.resolution_status == "pending"]), + } + + except Exception as e: + log.error(f"获取违规统计失败: {e}") + raise + + +async def get_frequency_limit_status_service( + db: AsyncSession, + user_id: int +) -> Dict[str, Any]: + """ + 获取用户的频率限制状态。 + + Args: + db: 数据库会话 + user_id: 用户ID + + Returns: + 包含频率限制状态的字典 + """ + try: + from core.security import freq_limiter, ViolationLogger + from models.user_account import UserAccount + + # 检查用户是否存在 + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == user_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise ItemNotFoundException(f"User with ID {user_id} not found") + + # 获取当前请求频率 + current_frequency = freq_limiter.get_frequency(user_id) + + # 获取违规记录数 + violation_count = await ViolationLogger.get_violation_count( + db, user_id, time_hours=24 + ) + + return { + "user_id": user_id, + "username": user.username, + "current_frequency": current_frequency, + "frequency_threshold": 20, + "time_window_seconds": 10, + "violation_count_24h": violation_count, + "auto_suspend_threshold": 3, + "status": user.status, + "is_banned": user.status == 'banned', + "is_suspended": user.status == 'suspended', + } + + except Exception as e: + log.error(f"获取频率限制状态失败: {e}") + raise + + +async def reset_frequency_limit_service( + db: AsyncSession, + user_id: int, + admin_id: int = 0 +) -> Dict[str, Any]: + """ + 管理员重置用户的频率限制(清空 Redis 计数器)。 + + Args: + db: 数据库会话 + user_id: 用户ID + admin_id: 执行重置的管理员ID + + Returns: + 包含操作结果的字典 + """ + try: + from core.security import freq_limiter + from models.user_account import UserAccount + + # 检查用户是否存在 + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == user_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise ItemNotFoundException(f"User with ID {user_id} not found") + + # 重置频率 + success = freq_limiter.reset_frequency(user_id) + + if success: + log.info(f"管理员 {admin_id} 重置了用户 {user_id} 的频率限制") + return { + "success": True, + "message": f"用户 {user_id} 的频率限制已重置", + "user_id": user_id, + } + else: + return { + "success": False, + "message": "重置频率限制失败", + } + + except Exception as e: + log.error(f"重置频率限制失败: {e}") + raise diff --git a/src/backend/app/service/ai_service.py b/src/backend/app/service/ai_service.py new file mode 100644 index 0000000..e872690 --- /dev/null +++ b/src/backend/app/service/ai_service.py @@ -0,0 +1,179 @@ +# backend/app/service/ai_service.py +import requests +import json +import random +import string +from bs4 import BeautifulSoup +from openai import OpenAI +from core.log import log +from core.config import settings +from core.prompts import MERMAID_ER_GENERATION_PROMPT, build_mermaid_init_directive + +class AIService: + """ + 项目 Schema 生成工具类。 + + Methods: + _parse_html_schema_only(html_content: str) -> str: 解析 HTML 获取 Schema。 + _request_ddl_remote(...): 远程调用 DDL 生成接口。 + run_generation(...): 执行 Schema 和 DDL 生成流程。 + """ + BASE_HOST = "http://43.154.73.48:5000" + DDL_API_URL = "https://schema2ddl.strangeloop.fun/generate/ddl" + + # --- Schema 生成逻辑 --- + @classmethod + def generate_schema(cls, requirements: str, db_name: str, db_type: str, ai_model: str = "gpt4") -> str: + """ + 仅生成 Schema (Logical Design) + """ + session_hash = ''.join(random.choices(string.ascii_lowercase + string.digits, k=11)) + inputs = [ai_model, db_name, requirements, db_type] + headers = {"Content-Type": "application/json"} + schema_res = "" + + try: + resp = requests.post( + f"{cls.BASE_HOST}/gradio_api/queue/join", + json={"data": inputs, "session_hash": session_hash, "fn_index": 0}, + headers=headers, timeout=10 + ) + if resp.status_code != 200: + log.error(f"[SchemaGen] Step 1 Submission failed: {resp.text}") + return "" + + resp = requests.get( + f"{cls.BASE_HOST}/gradio_api/queue/data?session_hash={session_hash}", + headers=headers, stream=True, timeout=120 + ) + + for line in resp.iter_lines(): + if line: + decoded = line.decode('utf-8') + if decoded.startswith('data: '): + try: + msg = json.loads(decoded[6:]) + if msg.get('msg') == 'process_completed': + output_data = msg.get('output', {}).get('data', []) + if output_data: + schema_res = cls._parse_html_schema_only(output_data[0]) + except: + continue + return schema_res + except Exception as e: + log.error(f"[SchemaGen] Step 1 Error: {e}") + return "" + + @staticmethod + def _parse_html_schema_only(html_content: str) -> str: + """ + 仅解析 HTML 提取 Schema (Logical Design),忽略 DDL + """ + if not html_content: + return "" + soup = BeautifulSoup(html_content, 'html.parser') + + # 1. 提取 Schema (Logical Design) + schema_text = [] + # 使用模糊匹配找到 Logical Design 章节 + start_node = soup.find(lambda tag: tag.name in ['h1', 'h2', 'h3', 'h4'] and 'Logical Design' in tag.get_text()) + if start_node: + current = start_node.find_next_sibling() + while current: + # 遇到下一个大标题就停止 + if current.name in ['h1', 'h2', 'h3', 'h4']: + break + text = current.get_text(separator='\n', strip=True) + if text: + schema_text.append(text) + current = current.find_next_sibling() + + return "\n\n".join(schema_text) + + # --- DDL 生成逻辑 --- + @classmethod + def generate_ddl(cls, schema_text: str, requirements: str, db_type: str, ai_model: str = "gpt4") -> str: + """ + 根据 Schema 和需求生成 DDL + """ + payload = { + "database_requirment": requirements, + "schema": schema_text, + "target_db_type": db_type, + "model": ai_model + } + try: + resp = requests.post(cls.DDL_API_URL, json=payload, timeout=120) + if resp.status_code == 200: + res_json = resp.json() + return res_json.get("ddl_statements", "") + else: + log.error(f"[SchemaGen] DDL API failed: {resp.status_code} - {resp.text}") + return "" + except Exception as e: + log.error(f"[SchemaGen] DDL API Exception: {e}") + return "" + + # --- Mermaid ER 图生成逻辑 --- + @classmethod + def generate_mermaid_code(cls, schema_text: str, ai_model: str = "gpt4") -> str: + """ + 根据 Schema 生成 Mermaid ER 图代码。 + + Args: + schema_text: Schema 文本 + ai_model: AI 模型标识 + + Returns: + str: Mermaid ER 图代码(包含主题配置) + """ + api_key = settings.ai.mermaid_api_key + base_url = "https://ai.nengyongai.cn/v1" + + if not api_key: + log.warning("[ERGen] No API Key found.") + return "" + + # 模型映射 + model_mapping = { + 'gpt4': 'gpt-4o-2024-08-06', + 'chatgpt': 'gpt-3.5-turbo', + 'qwen': 'Qwen/Qwen3-32B', + 'deepseek': 'deepseek-v3-241226' + } + real_model = model_mapping.get(ai_model, 'gpt-4o-2024-08-06') + + try: + client = OpenAI(api_key=api_key, base_url=base_url) + + response = client.chat.completions.create( + model=real_model, + messages=[ + { + "role": "user", + "content": f"{MERMAID_ER_GENERATION_PROMPT}\n\nRequirement:\n{schema_text}" + } + ], + temperature=0.1 + ) + + content = response.choices[0].message.content + content = content.replace("```mermaid", "").replace("```", "").strip() + + # 确保 content 以 erDiagram 开头,如果不是则添加换行符 + if not content.startswith('erDiagram'): + # 如果内容不是以 erDiagram 开头,可能需要添加换行符 + content = content.lstrip() + + # 拼接主题初始化指令,确保格式正确 + result = build_mermaid_init_directive() + content + + # 记录生成的内容用于调试 + log.info(f"[ERGen] Generated ER diagram code length: {len(result)}") + log.debug(f"[ERGen] Generated ER diagram preview: {result[:200]}...") + + return result + + except Exception as e: + log.error(f"[ERGen] Error generating mermaid code: {e}") + return "" \ No newline at end of file diff --git a/src/backend/app/service/announcement_cache_service.py b/src/backend/app/service/announcement_cache_service.py new file mode 100644 index 0000000..2b4d692 --- /dev/null +++ b/src/backend/app/service/announcement_cache_service.py @@ -0,0 +1,254 @@ +""" +公告缓存服务。 + +提供公告的 Redis 缓存功能,包括列表缓存、详情缓存和缓存更新。 +""" + +import json +import logging +from typing import List, Optional, Dict, Any, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from redis_client.redis_keys import redis_key_manager + +from models.system_announcement import SystemAnnouncement as Announcement +from redis_client.redis import get_redis +from core.log import log + +# 缓存过期时间(秒):1小时 +CACHE_TTL = 3600 + +class AnnouncementCacheService: + """公告缓存服务类。""" + + @staticmethod + async def _get_redis(): + """获取Redis连接。""" + redis_conn = get_redis() + if not redis_conn: + log.warning("Redis connection not available") + return None + return redis_conn + + @staticmethod + async def get_list_from_cache(page: int, page_size: int) -> Optional[Tuple[int, List[Dict]]]: + """ + 从缓存获取公告列表。 + + Args: + page (int): 页码 + page_size (int): 每页数量 + + Returns: + Optional[Tuple[int, List[Dict]]]: (总数, 公告列表) 或 None + """ + redis_conn = await AnnouncementCacheService._get_redis() + if not redis_conn: + return None + + cache_key = redis_key_manager.get_announcement_list_key(page, page_size) + try: + cached_data = await redis_conn.get(cache_key) + if cached_data: + data = json.loads(cached_data) + log.info(f"Cache hit for announcement list: {cache_key}") + return data["total"], data["items"] + except Exception as e: + log.error(f"Error getting announcement list from cache: {e}") + + return None + + @staticmethod + async def set_list_to_cache(page: int, page_size: int, total: int, items: List[Dict]) -> None: + """ + 设置公告列表到缓存。 + + Args: + page (int): 页码 + page_size (int): 每页数量 + total (int): 总数 + items (List[Dict]): 公告列表 + """ + redis_conn = await AnnouncementCacheService._get_redis() + if not redis_conn: + return + + cache_key = redis_key_manager.get_announcement_list_key(page, page_size) + try: + cache_data = { + "total": total, + "items": items + } + await redis_conn.setex(cache_key, CACHE_TTL, json.dumps(cache_data, default=str)) + log.info(f"Cache set for announcement list: {cache_key}") + except Exception as e: + log.error(f"Error setting announcement list to cache: {e}") + + @staticmethod + async def get_detail_from_cache(announcement_id: int) -> Optional[Dict]: + """ + 从缓存获取公告详情。 + + Args: + announcement_id (int): 公告ID + + Returns: + Optional[Dict]: 公告详情或 None + """ + redis_conn = await AnnouncementCacheService._get_redis() + if not redis_conn: + return None + + cache_key = redis_key_manager.get_announcement_detail_key(announcement_id) + try: + cached_data = await redis_conn.get(cache_key) + if cached_data: + data = json.loads(cached_data) + log.info(f"Cache hit for announcement detail: {announcement_id}") + return data + except Exception as e: + log.error(f"Error getting announcement detail from cache: {e}") + + return None + + @staticmethod + async def set_detail_to_cache(announcement_id: int, announcement_data: Dict) -> None: + """ + 设置公告详情到缓存。 + + Args: + announcement_id (int): 公告ID + announcement_data (Dict): 公告数据 + """ + redis_conn = await AnnouncementCacheService._get_redis() + if not redis_conn: + return + + cache_key = redis_key_manager.get_announcement_detail_key(announcement_id) + try: + await redis_conn.setex(cache_key, CACHE_TTL, json.dumps(announcement_data, default=str)) + log.info(f"Cache set for announcement detail: {announcement_id}") + except Exception as e: + log.error(f"Error setting announcement detail to cache: {e}") + + @staticmethod + async def invalidate_list_cache() -> None: + """ + 清除公告列表缓存。 + 当公告有新增、更新或删除时调用。 + """ + redis_conn = await AnnouncementCacheService._get_redis() + if not redis_conn: + return + + try: + # 使用 SCAN 命令查找并删除所有公告列表缓存 + pattern = redis_key_manager.get_announcement_list_pattern() + cursor = 0 + while True: + cursor, keys = await redis_conn.scan(cursor=cursor, match=pattern, count=100) + if keys: + await redis_conn.delete(*keys) + log.info(f"Deleted announcement list cache keys: {keys}") + if cursor == 0: + break + log.info("Announcement list cache invalidated") + except Exception as e: + log.error(f"Error invalidating announcement list cache: {e}") + + @staticmethod + async def invalidate_detail_cache(announcement_id: int) -> None: + """ + 清除指定公告的详情缓存。 + + Args: + announcement_id (int): 公告ID + """ + redis_conn = await AnnouncementCacheService._get_redis() + if not redis_conn: + return + + cache_key = redis_key_manager.get_announcement_detail_key(announcement_id) + try: + await redis_conn.delete(cache_key) + log.info(f"Deleted announcement detail cache: {announcement_id}") + except Exception as e: + log.error(f"Error invalidating announcement detail cache: {e}") + + @staticmethod + async def invalidate_all_announcement_cache() -> None: + """ + 清除所有公告相关缓存(列表和详情)。 + """ + redis_conn = await AnnouncementCacheService._get_redis() + if not redis_conn: + return + + try: + # 清除列表缓存 + await AnnouncementCacheService.invalidate_list_cache() + + # 清除所有详情缓存 + pattern = redis_key_manager.get_announcement_detail_pattern() + cursor = 0 + while True: + cursor, keys = await redis_conn.scan(cursor=cursor, match=pattern, count=100) + if keys: + await redis_conn.delete(*keys) + log.info(f"Deleted announcement detail cache keys: {keys}") + if cursor == 0: + break + log.info("All announcement cache invalidated") + except Exception as e: + log.error(f"Error invalidating all announcement cache: {e}") + + @staticmethod + async def get_fresh_data_from_db(db: AsyncSession, page: int, page_size: int) -> Tuple[int, List[Dict]]: + """ + 从数据库获取最新的公告列表数据。 + + Args: + db (AsyncSession): 数据库会话 + page (int): 页码 + page_size (int): 每页数量 + + Returns: + Tuple[int, List[Dict]]: (总数, 公告列表) + """ + try: + # 查询总数 + count_query = select(func.count()).select_from( + select(Announcement) + .where(Announcement.status == "published") + .subquery() + ) + total = (await db.execute(count_query)).scalar() or 0 + + # 查询分页数据 + query = ( + select(Announcement) + .where(Announcement.status == "published") + .order_by(Announcement.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + rows = (await db.execute(query)).scalars().all() + + # 转换为字典列表 + items = [] + for row in rows: + item = { + "announcement_id": row.announcement_id, + "title": row.title, + "content": row.content, + "status": row.status, + "created_by": row.created_by, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None + } + items.append(item) + + return total, items + except Exception as e: + log.error(f"Error getting fresh data from database: {e}") + return 0, [] diff --git a/src/backend/app/service/announcement_service.py b/src/backend/app/service/announcement_service.py new file mode 100644 index 0000000..df1f89a --- /dev/null +++ b/src/backend/app/service/announcement_service.py @@ -0,0 +1,74 @@ +""" +公告服务。 + +为用户侧提供公告列表与详情的只读访问;创建与更新由管理员服务处理。 +""" + +# backend/app/service/announcement_service.py + +from sqlalchemy.ext.asyncio import AsyncSession +from crud.crud_announcement import crud_announcement +from typing import Any, Dict + + +class AnnouncementService: + """ + 公告查询服务。 + + 提供公告列表与详情的读取方法,适用于用户端展示。 + """ + + @staticmethod + async def get_announcement_list( + db: AsyncSession, + status: str | None, + page: int, + page_size: int + ) -> Dict[str, Any]: + """ + 获取公告列表。 + + Args: + db (AsyncSession): 数据库会话。 + status (str | None): 公告状态过滤。 + page (int): 页码。 + page_size (int): 每页数量。 + + Returns: + dict: 包含分页信息与公告条目的字典。 + """ + total, items = await crud_announcement.get_list( + db=db, + status=status, + page=page, + page_size=page_size + ) + + return { + "total": total, + "page": page, + "page_size": page_size, + "items": items + } + + @staticmethod + async def get_announcement_detail( + db: AsyncSession, + announcement_id: int + ) -> Any: + """ + 获取公告详情。 + + Args: + db (AsyncSession): 数据库会话。 + announcement_id (int): 公告 ID。 + + Returns: + Any: 公告 ORM 对象或序列化结果。 + """ + announcement = await crud_announcement.get(db, announcement_id) + return announcement + + +# 单例实例 +announcement_service = AnnouncementService() diff --git a/src/backend/app/service/chat_service.py b/src/backend/app/service/chat_service.py new file mode 100644 index 0000000..8692675 --- /dev/null +++ b/src/backend/app/service/chat_service.py @@ -0,0 +1,1395 @@ +""" +聊天服务。 + +处理与 AI 模型的聊天交互,负责 SQL 生成任务。 +包含: +- 模型注册与配置管理 +- 会话所有权校验 +- AI 响应的解析与格式化 +- 会话模型记忆逻辑 + +重构说明: +1. 将长 AI 调用移出数据库事务,确保连接池不会被耗尽 +2. 拆分 process_chat 为多个小函数,每个函数控制在 50 行以内 +3. Prompt 模板移至 core/prompts.py +4. 模型配置支持从数据库动态读取,硬编码配置作为后备 +""" + +# backend/app/service/chat_service.py + +import json +import httpx +import sqlparse +from typing import List, Dict, Any, Optional, Tuple +from fastapi import HTTPException +from sqlalchemy.future import select +from sqlalchemy import update +from sqlalchemy.orm import selectinload +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi.encoders import jsonable_encoder +from core.exceptions import ForbiddenException, ItemNotFoundException, InvalidOperationException, BusinessException, AppException + +from core.config import settings +from core.log import log +from core.prompts import build_ai_messages +from crud.crud_database_instance import crud_database_instance +from crud.crud_message import crud_message +from crud.crud_project import crud_project +from crud.crud_knowledge import crud_knowledge +from crud.crud_ai_model_config import crud_ai_model_config +from models.session import Session as SessionModel +from schema.chat import ChatResponse, MessageType +from service.mysql_service import execute_mysql_sql_with_user_check +from service.postgresql_service import execute_postgres_sql_with_user_check +from service.sqlite_service import execute_sqlite_sql_with_user_check as execute_sqlite +from models.ai_generated_statement import AIGeneratedStatement +from models.query_result import QueryResult +from models.message import Message as MessageModel +from models.project import Project as ProjectModel +from models.domain_knowledge import DomainKnowledge + +# RAG 服务导入 +from service.rag_service import rag_service, retrieve_chat_context, index_new_message + + +# ========================================================= +# 1. 模型配置注册表(后备配置,当数据库无配置时使用) +# ========================================================= + +FALLBACK_MODEL_REGISTRY = { + "my-finetuned-sql": { + "name": "My Fine-Tuned SQL Model", + "api_url": "http://1.92.127.206:8080/v1/chat/completions", + "model_id": "codellama/CodeLlama-13b-Instruct-hf", + "api_key": "sk-2025texttosql", + "type": "local_finetune" + }, + "xiyan-sql": { + "name": "XiYan-SQL (QwenCoder-32B)", + "api_url": "https://api-inference.modelscope.cn/v1/chat/completions", + "model_id": "XGenerationLab/XiYanSQL-QwenCoder-32B-2504", + "api_key": settings.ai.modelscope_api_key, + "type": "general_llm" + }, + "qwen-coder-32b": { + "name": "Qwen2.5-Coder-32B", + "api_url": "https://api-inference.modelscope.cn/v1/chat/completions", + "model_id": "Qwen/Qwen2.5-Coder-32B-Instruct", + "api_key": settings.ai.modelscope_api_key, + "type": "general_llm" + }, + "deepseek-v3": { + "name": "DeepSeek V3.1", + "api_url": "https://api-inference.modelscope.cn/v1/chat/completions", + "model_id": "deepseek-ai/DeepSeek-V3.1", + "api_key": settings.ai.modelscope_api_key, + "type": "general_llm" + } +} + +# 保留旧变量名以兼容可能的外部引用 +MODEL_REGISTRY = FALLBACK_MODEL_REGISTRY + +DEFAULT_MODEL = "my-finetuned-sql" + + +# ========================================================= +# 1.1 动态模型配置获取 +# ========================================================= + +async def get_model_registry(db: AsyncSession) -> Dict[str, Dict[str, Any]]: + """ + 获取模型配置注册表。 + + 优先从数据库读取,如果数据库无配置则使用后备配置。 + + Args: + db (AsyncSession): 数据库会话。 + + Returns: + Dict[str, Dict[str, Any]]: 模型配置字典。 + """ + try: + db_registry = await crud_ai_model_config.get_model_registry(db) + if db_registry: + return db_registry + except Exception as e: + log.warning(f"Failed to load model config from database: {e}, using fallback") + + return FALLBACK_MODEL_REGISTRY + + +async def get_default_model_key(db: AsyncSession) -> str: + """ + 获取默认模型名称。 + + 由于移除了 is_default 字段,这里返回第一个配置的模型名称。 + 如果数据库无配置则使用后备默认值。 + + Args: + db (AsyncSession): 数据库会话。 + + Returns: + str: 默认模型名称。 + """ + try: + configs = await crud_ai_model_config.get_all(db, limit=1) + if configs: + return configs[0].model_name + except Exception as e: + log.warning(f"Failed to load default model from database: {e}, using fallback") + + return DEFAULT_MODEL + + +# ========================================================= +# 2. 会话与权限校验 +# ========================================================= + +async def _verify_session_ownership(db: AsyncSession, session_id: int, user_id: int) -> int: + """验证会话所有权,防止越权。返回 project_id。""" + stmt = ( + select(SessionModel) + .options(selectinload(SessionModel.project)) + .where(SessionModel.session_id == session_id) + ) + result = await db.execute(stmt) + session = result.scalar_one_or_none() + + if not session: + raise ItemNotFoundException(message="会话未找到") + + if not session.project: + raise ItemNotFoundException(message="此会话的项目未找到") + + if session.project.user_id != user_id: + log.warning("Security Alert: User {} tried to access session {}", user_id, session_id) + raise ForbiddenException(message="权限拒绝") + + return session.project_id + + +async def _get_session_obj(db: AsyncSession, session_id: int) -> SessionModel: + """获取会话对象。""" + stmt = select(SessionModel).where(SessionModel.session_id == session_id) + result = await db.execute(stmt) + session_obj = result.scalar_one_or_none() + if not session_obj: + raise ItemNotFoundException(message="会话已丢失") + return session_obj + + +# ========================================================= +# 3. 模型选择策略 +# ========================================================= + +async def _resolve_model_key( + db: AsyncSession, + selected_model: Optional[str], + session_current_model: Optional[str] +) -> Tuple[str, Dict[str, Dict[str, Any]]]: + """ + 解析最终使用的模型 key。 + 优先级:用户本次指定 > 会话记忆 > 默认模型 + + 同时返回模型注册表,避免重复查询数据库。 + + Returns: + Tuple[str, Dict]: (模型 key, 模型配置注册表) + """ + # 获取模型注册表 + registry = await get_model_registry(db) + default_key = await get_default_model_key(db) + + if selected_model and selected_model in registry: + return selected_model, registry + if session_current_model and session_current_model in registry: + return session_current_model, registry + + return default_key, registry + + +async def _update_session_model( + db: AsyncSession, + session_obj: SessionModel, + new_model: str +) -> None: + """更新会话的当前模型(如果有变化)。""" + if session_obj.current_model != new_model: + session_obj.current_model = new_model + db.add(session_obj) + + +# ========================================================= +# 3.1 RAG 结果转换辅助函数 +# ========================================================= + +async def _convert_rag_history_to_messages( + db: AsyncSession, + rag_results: List[Dict[str, Any]] +) -> List[MessageModel]: + """ + 将 RAG 检索的历史结果转换为 MessageModel 兼容格式。 + + Args: + db: 数据库会话 + rag_results: RAG 检索结果列表 + + Returns: + List[MessageModel]: 消息列表 + """ + if not rag_results: + return [] + + # 从 RAG 结果中提取 message_id + message_ids = [] + for result in rag_results: + metadata = result.get("metadata", {}) + msg_id = metadata.get("message_id") + if msg_id: + message_ids.append(msg_id) + + if not message_ids: + return [] + + # 从数据库获取完整的消息对象 + from sqlalchemy import select + stmt = select(MessageModel).where(MessageModel.message_id.in_(message_ids)) + result = await db.execute(stmt) + messages = result.scalars().all() + + # 按 RAG 相关性顺序排序(保持检索顺序) + message_map = {msg.message_id: msg for msg in messages} + ordered_messages = [] + for result in rag_results: + msg_id = result.get("metadata", {}).get("message_id") + if msg_id and msg_id in message_map: + ordered_messages.append(message_map[msg_id]) + + return ordered_messages + + +async def _convert_rag_knowledge_to_domain( + db: AsyncSession, + rag_results: List[Dict[str, Any]] +) -> List[DomainKnowledge]: + """ + 将 RAG 检索的知识结果转换为 DomainKnowledge 兼容格式。 + + Args: + db: 数据库会话 + rag_results: RAG 检索结果列表 + + Returns: + List[DomainKnowledge]: 领域知识列表 + """ + if not rag_results: + return [] + + # 从 RAG 结果中提取 knowledge_id + knowledge_ids = [] + for result in rag_results: + metadata = result.get("metadata", {}) + k_id = metadata.get("knowledge_id") + if k_id: + knowledge_ids.append(k_id) + + if not knowledge_ids: + return [] + + # 从数据库获取完整的知识对象 + from sqlalchemy import select + stmt = select(DomainKnowledge).where(DomainKnowledge.knowledge_id.in_(knowledge_ids)) + result = await db.execute(stmt) + knowledge_items = result.scalars().all() + + # 按 RAG 相关性顺序排序 + knowledge_map = {k.knowledge_id: k for k in knowledge_items} + ordered_knowledge = [] + for result in rag_results: + k_id = result.get("metadata", {}).get("knowledge_id") + if k_id and k_id in knowledge_map: + ordered_knowledge.append(knowledge_map[k_id]) + + return ordered_knowledge + + +def _build_ddl_from_rag_results(rag_results: List[Dict[str, Any]]) -> str: + """ + 从 RAG 检索结果构建 DDL 文本。 + + 将分片检索的表DDL重新组合为完整的DDL文本。 + 外键关联的表会自动包含在结果中(由RAG服务保证)。 + + Args: + rag_results: RAG 检索结果列表 + + Returns: + str: 组合后的DDL文本 + """ + if not rag_results: + return "-- No DDL found" + + ddl_fragments = [] + seen_tables = set() + + for result in rag_results: + # 从不同可能的字段中获取DDL内容 + ddl_content = ( + result.get("content") or + result.get("document") or + result.get("metadata", {}).get("ddl_fragment") or + "" + ) + + # 获取表名(用于去重) + table_name = ( + result.get("metadata", {}).get("table_name") or + result.get("table_name") or + "" + ) + + if ddl_content and table_name.lower() not in seen_tables: + ddl_fragments.append(ddl_content.strip()) + if table_name: + seen_tables.add(table_name.lower()) + + if not ddl_fragments: + return "-- No DDL found" + + # 添加注释说明这是RAG检索的部分DDL + header = "-- [RAG Retrieved Schema - Related Tables]\n" + return header + "\n\n".join(ddl_fragments) + + +# ========================================================= +# 4. AI 调用(事务外执行) +# ========================================================= + + +# 注意:大多数场景可保持同步,用户体验更好 + +def _clean_ai_response(content: str) -> str: + """清洗 AI 返回的 SQL 内容。""" + # 移除所有可能的停止符(INST 格式 + ChatML 格式兜底) + stop_tokens = [ + "[/INST]", "[INST]", "<>", "<>", + "<|im_end|>", "<|im_start|>", # ChatML 格式兜底清理 + "<|endoftext|>", "<|end|>", "", "" + ] + for stop_token in stop_tokens: + if stop_token in content: + content = content.split(stop_token)[0] + + # 去除 Markdown 标记 + clean_sql = content.strip().replace("```sql", "").replace("```", "").strip() + + # 移除可能的前缀文本(如"已生成sql语句:"等) + prefixes_to_remove = [ + "已生成sql语句:", + "已生成SQL语句:", + "生成的SQL语句:", + "SQL语句:", + "查询语句:" + ] + + for prefix in prefixes_to_remove: + if clean_sql.startswith(prefix): + clean_sql = clean_sql[len(prefix):].strip() + + # 只取第一条 SQL + if ";\n" in clean_sql: + clean_sql = clean_sql.split(";\n")[0] + ";" + elif clean_sql.count(";") > 1: + clean_sql = clean_sql.split(";")[0] + ";" + + return clean_sql + + +async def call_ai_agent( + ddl_text: str, + question: str, + history: List[MessageModel] = None, + knowledge: List[DomainKnowledge] = None, + model_key: str = None, + model_registry: Dict[str, Dict[str, Any]] = None, + db_type: str = None +) -> str: + """ + 调用 AI 接口生成 SQL。 + + 【重要】此函数不应在数据库事务内调用,因为 AI 调用可能耗时很长(最长 300s)。 + + Args: + ddl_text: 数据库 DDL 语句 + question: 用户问题 + history: 历史消息列表 + knowledge: 领域知识列表 + model_key: 模型标识符 + model_registry: 模型配置注册表(从数据库或后备配置获取) + db_type: 数据库类型(mysql/postgresql/sqlite) + """ + history = history or [] + knowledge = knowledge or [] + + # 使用传入的注册表或后备配置 + registry = model_registry or FALLBACK_MODEL_REGISTRY + + if not model_key or model_key not in registry: + model_key = DEFAULT_MODEL if DEFAULT_MODEL in registry else list(registry.keys())[0] + + config = registry[model_key] + log.info(f"Using AI Model: {config['name']} ({config['model_id']})") + + ai_input = build_ai_messages( + model_type=config["type"], + schema_text=ddl_text, + question=question, + history=history, + knowledge=knowledge, + db_type=db_type + ) + + # 根据返回类型决定使用 completions 还是 chat completions 端点 + if isinstance(ai_input, dict) and ai_input.get("is_completion"): + # 微调模型:使用 /v1/completions 端点,直接发送格式化的 prompt + # 这样可以避免服务器应用错误的 chat template (如 ChatML) + payload = { + "model": config["model_id"], + "prompt": ai_input["prompt"], + "temperature": 0.1, + "stream": False, + "max_tokens": 512, + "stop": ["[/INST]", "[INST]", "<>", "<>", "\n\n\n"] + } + use_completion_api = True + else: + # 在线模型:使用 /v1/chat/completions 端点 + payload = { + "model": config["model_id"], + "messages": ai_input, + "temperature": 0.1, + "stream": False, + "max_tokens": 512, + "stop": ["User:", "Assistant:", "\n\n\n"] + } + use_completion_api = False + + headers = { + "Authorization": f"Bearer {config['api_key']}", + "Content-Type": "application/json" + } + + try: + # 根据模型类型选择 API 端点 + if use_completion_api: + # 微调模型:使用 /v1/completions 端点 + # 将 /v1/chat/completions 替换为 /v1/completions + api_url = config["api_url"].replace("/chat/completions", "/completions") + else: + api_url = config["api_url"] + + # 设置细粒度超时: + # - connect: 10秒连接超时,快速检测服务不可用 + # - read: 120秒读取超时,等待AI模型响应 + # - write: 30秒写入超时 + # - pool: 10秒连接池超时 + timeout_config = httpx.Timeout( + connect=10.0, # 连接超时:快速检测AI服务是否可达 + read=120.0, # 读取超时:等待AI模型生成响应 + write=30.0, # 写入超时 + pool=10.0 # 连接池超时 + ) + async with httpx.AsyncClient(timeout=timeout_config) as client: + resp = await client.post( + api_url, + content=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers=headers + ) + resp.raise_for_status() + raw = resp.json() + + content = "" + if "choices" in raw and len(raw["choices"]) > 0: + if use_completion_api: + # completions 端点返回 text 字段 + content = raw["choices"][0].get("text", "") + else: + # chat completions 端点返回 message.content + content = raw["choices"][0]["message"]["content"] + + return _clean_ai_response(content) + + except httpx.ConnectError as e: + log.error("AI Connect Error ({}): {}", model_key, e) + return f"-- AI Service Error: 无法连接到AI服务 ({config['api_url'][:50]}...)" + except httpx.ConnectTimeout as e: + log.error("AI Connect Timeout ({}): {}", model_key, e) + return f"-- AI Service Error: 连接AI服务超时,服务可能不可用" + except httpx.ReadTimeout as e: + log.error("AI Read Timeout ({}): {}", model_key, e) + return f"-- AI Service Error: AI服务响应超时,请稍后重试" + except httpx.HTTPStatusError as e: + log.error("AI HTTP Error ({}): {} - {}", model_key, e.response.status_code, e.response.text[:200]) + return f"-- AI Service Error: AI服务返回错误 (HTTP {e.response.status_code})" + except Exception as e: + error_msg = str(e) if str(e) else type(e).__name__ + log.error("AI Call Error ({}): {}", model_key, error_msg) + return f"-- AI Service Error: {error_msg}" + + +# ========================================================= +# 5. SQL 解析与执行 +# ========================================================= + +def _parse_sql_type(sql_text: str) -> str: + """解析 SQL 语句类型。""" + sql_type = "UNKNOWN" + try: + if sql_text and not sql_text.startswith("--"): + # 先清洗SQL文本,移除可能的前缀 + clean_sql = sql_text.strip() + + # 移除可能的前缀文本 + prefixes_to_remove = [ + "已生成sql语句:", + "已生成SQL语句:", + "生成的SQL语句:", + "SQL语句:", + "查询语句:" + ] + + for prefix in prefixes_to_remove: + if clean_sql.startswith(prefix): + clean_sql = clean_sql[len(prefix):].strip() + + # 使用sqlparse解析 + parsed = sqlparse.parse(clean_sql) + if parsed: + sql_type = parsed[0].get_type().upper() + + # 兜底逻辑:如果sqlparse无法识别,使用简单的关键字匹配 + if sql_type == "UNKNOWN": + clean_upper = clean_sql.strip().upper() + if clean_upper.startswith("SELECT"): + sql_type = "SELECT" + elif clean_upper.startswith("INSERT"): + sql_type = "INSERT" + elif clean_upper.startswith("UPDATE"): + sql_type = "UPDATE" + elif clean_upper.startswith("DELETE"): + sql_type = "DELETE" + elif clean_upper.startswith("CREATE"): + sql_type = "CREATE" + elif clean_upper.startswith("DROP"): + sql_type = "DROP" + elif clean_upper.startswith("ALTER"): + sql_type = "ALTER" + elif clean_upper.startswith("TRUNCATE"): + sql_type = "TRUNCATE" + + except Exception as e: + log.warning("SQL parsing failed for: {}..., error: {}", sql_text[:100], e) + + return sql_type + + +def _is_ai_service_error(sql_text: str) -> tuple: + """ + 检测是否为AI服务调用错误。 + + Returns: + tuple: (is_error: bool, error_message: str) + """ + if not sql_text: + return False, "" + + # 检测 AI 服务错误标记 + if sql_text.startswith("-- AI Service Error:"): + # 提取错误详情 + error_detail = sql_text.replace("-- AI Service Error:", "").strip() + return True, error_detail + + return False, "" + + +def _is_meta_sql(sql_text: str) -> bool: + """判断是否为元数据/错误 SQL(不包括AI服务错误)。""" + if not sql_text: + return False + + # 先排除 AI 服务错误(由专门的函数处理) + if sql_text.startswith("-- AI Service Error:"): + return False + + sql_upper = sql_text.upper() + + # 检查是否包含错误关键字 + error_patterns = [ + "'CANCELED'", + "'ERROR'", + "ERROR:", + "CANNOT ANSWER", + "无法回答", + "CAN'T ANSWER", + "UNABLE TO", + "NOT SUPPORTED" + ] + + return any(pattern in sql_upper for pattern in error_patterns) + + +def _requires_confirmation(sql_type: str) -> bool: + """判断 SQL 类型是否需要用户确认。""" + return sql_type in ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE", "CREATE", "DDL"] + + +def _is_forbidden_sql_type(sql_type: str) -> bool: + """判断 SQL 类型是否被禁止执行(DDL 类操作)。""" + return sql_type in ["DROP", "ALTER", "TRUNCATE", "CREATE", "DDL"] + + +def _normalize_sql_type_for_db(sql_type: str) -> str: + """ + 将 SQL 类型标准化为数据库允许的类型。 + 数据库约束: statement_type IN ('SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DDL', 'OTHER') + """ + if sql_type in ["SELECT", "INSERT", "UPDATE", "DELETE"]: + return sql_type + elif sql_type in ["CREATE", "DROP", "ALTER", "TRUNCATE"]: + return "DDL" + else: + return "OTHER" + + +async def _execute_sql_by_type(sql: str, sql_type: str, instance: Any, user_id: int): + """根据数据库类型分发执行逻辑。""" + if instance.db_type == 'mysql': + return await execute_mysql_sql_with_user_check(sql, sql_type, instance) + elif instance.db_type == 'postgresql': + return await execute_postgres_sql_with_user_check(sql, sql_type, instance) + elif instance.db_type == 'sqlite': + return await execute_sqlite(sql, sql_type, instance, user_id) + else: + raise InvalidOperationException(message=f"不支持的数据库类型: {instance.db_type}") + + +# ========================================================= +# 6. 消息持久化(独立事务)+ RAG 索引 +# ========================================================= + +async def _save_user_message( + db: AsyncSession, + session_id: int, + content: str +) -> MessageModel: + """保存用户消息(独立事务),并异步触发向量索引。""" + message = await crud_message.create_message(db, session_id, content, role="user") + + # 异步触发消息向量索引(不阻塞主流程) + try: + import asyncio + asyncio.create_task( + index_new_message( + session_id=session_id, + message_id=message.message_id, + content=content, + role="user" + ) + ) + except Exception as e: + # 索引失败不影响主流程 + log.warning(f"Failed to trigger message indexing: {e}") + + return message + + + +async def _save_ai_response( + db: AsyncSession, + session_id: int, + sql_text: str, + sql_type: str, + requires_confirm: bool, + execution_status: str, + data: List[Dict], + error_message: str = None +) -> Tuple[MessageModel, AIGeneratedStatement]: + """ + 保存 AI 响应和执行结果(独立事务),并异步触发向量索引。 + + Args: + error_message: 执行失败时的错误信息,如果提供则保存错误内容 + """ + # 确保sql_text是干净的,不包含前缀 + clean_sql_text = sql_text + prefixes_to_remove = [ + "已生成sql语句:", + "已生成SQL语句:", + "生成的SQL语句:", + "SQL语句:", + "查询语句:" + ] + + for prefix in prefixes_to_remove: + if clean_sql_text.startswith(prefix): + clean_sql_text = clean_sql_text[len(prefix):].strip() + + # 根据执行状态构建回复内容 + if execution_status == "failed" and error_message: + reply_content = f"❌ 执行失败:\n{error_message}" + else: + reply_content = f"已生成SQL语句:\n{clean_sql_text}" + + ai_message = await crud_message.create_message(db, session_id, reply_content, role="assistant") + + # 仅对执行失败的SQL进行向量索引,帮助AI避免类似错误 + # 正常执行成功的SQL不索引 + if execution_status == "failed" and error_message: + try: + import asyncio + index_content = f"错误SQL案例:\nSQL语句:{clean_sql_text}\n执行错误:{error_message}" + asyncio.create_task( + index_new_message( + session_id=session_id, + message_id=ai_message.message_id, + content=index_content, + role="assistant" + ) + ) + except Exception as e: + log.warning(f"Failed to trigger error case indexing: {e}") + + if requires_confirm: + ai_message.requires_confirmation = True + db.add(ai_message) + + safe_data = jsonable_encoder(data) + # 将 SQL 类型标准化为数据库允许的类型 + db_sql_type = _normalize_sql_type_for_db(sql_type) + new_statement = AIGeneratedStatement( + message_id=ai_message.message_id, + sql_text=clean_sql_text, # 存储干净的SQL文本 + statement_type=db_sql_type, + execution_status=execution_status, + execution_result=safe_data, + statement_order=1 + ) + db.add(new_statement) + await db.flush() + + # 仅对成功的查询落库 QueryResult + if execution_status == "success" and sql_type == "SELECT": + query_result = QueryResult( + statement_id=new_statement.statement_id, + result_data=safe_data, + data_summary=None, + chart_type="table", + ) + db.add(query_result) + + await db.commit() + return ai_message, new_statement + + +# ========================================================= +# 7. 核心业务逻辑(重构后) +# ========================================================= + +async def process_chat( + db: AsyncSession, + session_id: int, + user_input: str, + user_id: int, + selected_model: str = None +) -> ChatResponse: + """ + 处理用户聊天请求的主流程。 + + 【原子性修复】将流程拆分为三个阶段: + 1. 事务1:保存用户消息,获取上下文 + 2. 事务外:调用 AI(长连接,最长 300s) + 3. 事务2:保存 AI 响应和执行结果 + """ + # === 阶段1:验证权限并获取上下文 === + project_id = await _verify_session_ownership(db, session_id, user_id) + session_obj = await _get_session_obj(db, session_id) + + # 解析模型(异步,从数据库获取配置) + final_model_key, model_registry = await _resolve_model_key(db, selected_model, session_obj.current_model) + await _update_session_model(db, session_obj, final_model_key) + log.info("Session {} using model: {}", session_id, final_model_key) + + # 获取项目信息 + project = await crud_project.get(db, project_id) + full_ddl_text = project.ddl_statement if project and project.ddl_statement else "" + instance_id = project.instance_id + + # 获取数据库类型 + db_type = None + if instance_id: + database_instance = await crud_database_instance.get(db, instance_id) + if database_instance: + db_type = database_instance.db_type + + # === RAG 检索优化 === + # 1. 历史对话:向量检索与当前问题相关的历史 + # 2. 领域知识:向量检索相关业务术语 + # 3. DDL分片:向量检索相关表DDL,并自动扩展外键关联表 + + history_context = [] + knowledge_context = [] + ddl_text = "-- No DDL found" + + # 判断是否使用DDL分片检索(表数量阈值) + table_count = full_ddl_text.upper().count("CREATE TABLE") if full_ddl_text else 0 + use_ddl_rag = table_count > 5 # 表数量超过5个时启用DDL分片检索 + + try: + # 使用 RAG 检索相关上下文 + rag_context = await retrieve_chat_context( + query=user_input, + project_id=project_id, + session_id=session_id, + history_top_k=5, + knowledge_top_k=5, + enable_ddl=use_ddl_rag, + ddl_top_k=5 + ) + + # 将 RAG 检索结果转换为兼容格式 + # 历史对话:从向量检索结果构建 MessageModel 兼容对象 + if rag_context.relevant_history: + history_context = await _convert_rag_history_to_messages( + db, rag_context.relevant_history + ) + + # 领域知识:从向量检索结果构建 DomainKnowledge 兼容对象 + if rag_context.relevant_knowledge: + knowledge_context = await _convert_rag_knowledge_to_domain( + db, rag_context.relevant_knowledge + ) + + # DDL:从向量检索结果构建DDL文本 + if use_ddl_rag and rag_context.relevant_ddl: + ddl_text = _build_ddl_from_rag_results(rag_context.relevant_ddl) + # 详细日志:显示检索到的表名 + retrieved_tables = [ + r.get("metadata", {}).get("table_name") or r.get("table_name", "unknown") + for r in rag_context.relevant_ddl + ] + log.info(f"[RAG-DDL] Retrieved {len(rag_context.relevant_ddl)} tables: {retrieved_tables}") + else: + # 表数量少时使用全量DDL + ddl_text = full_ddl_text if full_ddl_text else "-- No DDL found" + log.info(f"[RAG-DDL] Using full DDL (table_count={table_count}, threshold=5)") + + # 详细日志:显示检索到的历史和知识 + if rag_context.relevant_history: + history_contents = [r.get("content", "")[:50] + "..." for r in rag_context.relevant_history[:3]] + log.info(f"[RAG-History] Retrieved {len(rag_context.relevant_history)} items: {history_contents}") + + log.info(f"[RAG-Summary] history={len(history_context)}, knowledge={len(knowledge_context)}, DDL_RAG={use_ddl_rag}") + + except Exception as e: + # RAG 检索失败,降级为传统方式 + log.warning(f"RAG retrieval failed, falling back to traditional method: {e}") + history_context = await crud_message.get_recent_messages(db, session_id, limit=20) + knowledge_context = await crud_knowledge.get_all_by_project(db, project_id) + ddl_text = full_ddl_text if full_ddl_text else "-- No DDL found" + + # 保存用户消息 + await _save_user_message(db, session_id, user_input) + + # 提交事务1,释放数据库连接 + await db.commit() + + # 检查取消指令(需要持久化提示消息) + if user_input.strip() in ["取消", "cancel", "Stop"]: + return await _handle_cancel_response(db, session_id) + + # === 阶段2:调用 AI(事务外,长连接) === + sql_text = await call_ai_agent( + ddl_text, + user_input, + history=history_context, + knowledge=knowledge_context, + model_key=final_model_key, + model_registry=model_registry, + db_type=db_type + ) + + # 优先检查 AI 服务错误(模型不可用、超时等) + is_ai_error, ai_error_detail = _is_ai_service_error(sql_text) + if is_ai_error: + return await _handle_ai_service_error_response(db, session_id, ai_error_detail) + + # 检查元数据 SQL(AI无法理解的请求) + if _is_meta_sql(sql_text): + return await _handle_meta_sql_response(db, session_id) + + # === 阶段3:解析、执行、持久化 === + sql_type = _parse_sql_type(sql_text) + requires_confirm = _requires_confirmation(sql_type) + + data = [] + execution_status = "pending" + execution_error = None + + # 检查是否为被禁止的 DDL 操作 + if _is_forbidden_sql_type(sql_type): + execution_status = "failed" + execution_error = f"执行失败:此类别sql无法使用" + data = [{"error": execution_error}] + elif not requires_confirm: + data, execution_status = await _try_execute_sql( + db, sql_text, sql_type, instance_id, user_id + ) + # 如果执行失败,从data中提取错误信息 + if execution_status == "failed" and data and isinstance(data, list) and len(data) > 0: + if isinstance(data[0], dict) and "error" in data[0]: + execution_error = data[0]["error"] + + ai_message, _ = await _save_ai_response( + db, session_id, sql_text, sql_type, + False if _is_forbidden_sql_type(sql_type) else requires_confirm, + execution_status, data, + error_message=execution_error # 传入错误信息 + ) + + # 构建响应内容 + if execution_error: + response_content = f"❌ 执行失败:\n{execution_error}" + elif execution_status == "failed": + response_content = f"❌ 执行失败" + else: + response_content = f"已生成SQL语句:\n{sql_text}" + + return ChatResponse( + message_id=ai_message.message_id, + content=response_content, + message_type=MessageType.ASSISTANT, + sql_text=sql_text, + sql_type=sql_type, + requires_confirmation=False if _is_forbidden_sql_type(sql_type) else requires_confirm, + data=jsonable_encoder(data) if data else ([{"error": execution_error}] if execution_error else None) + ) + + +async def _handle_cancel_response(db: AsyncSession, session_id: int) -> ChatResponse: + """持久化并返回取消操作的响应。""" + reply_content = "好的,已为您取消当前操作。" + ai_message = await crud_message.create_message(db, session_id, reply_content, role="assistant") + await db.commit() + + return ChatResponse( + message_id=ai_message.message_id, + content=reply_content, + message_type=MessageType.ASSISTANT, + sql_text=None, + sql_type="ACTION_CANCEL", + requires_confirmation=False, + data=None + ) + + +async def cancel_message( + db: AsyncSession, + message_id: int, + user_id: int +) -> ChatResponse: + """ + 取消需要确认的消息: + - 权限校验(会话归属) + - 将原消息标记为已确认(用于隐藏前端按钮) + - 将其关联的语句执行状态标记为 failed + - 更新原消息内容,附加取消提示(与 SQL 语句合并显示) + """ + log.info(f"cancel_message called: message_id={message_id}, user_id={user_id}") + + # 获取消息与会话信息 + stmt = select(MessageModel).where(MessageModel.message_id == message_id) + result = await db.execute(stmt) + message = result.scalar_one_or_none() + if not message: + log.warning(f"Message not found: message_id={message_id}") + raise ItemNotFoundException(message="消息未找到") + + # 权限校验:仅会话所属用户可操作 + await _verify_session_ownership(db, message.session_id, user_id) + + # 获取关联的 SQL 语句 + stmt_query = select(AIGeneratedStatement).where(AIGeneratedStatement.message_id == message_id) + stmt_result = await db.execute(stmt_query) + ai_statement = stmt_result.scalar_one_or_none() + + sql_text = ai_statement.sql_text if ai_statement else None + sql_type = ai_statement.statement_type if ai_statement else "UNKNOWN" + + # 标记原消息为"已确认"(用于隐藏确认/取消按钮) + message.user_confirmed = True + # 更新原消息内容,附加取消提示 + message.content = f"{message.content}❌ 已取消执行" + db.add(message) + + # 更新关联的语句执行状态为 failed(数据库约束只允许 pending/completed/failed) + if ai_statement: + await db.execute( + update(AIGeneratedStatement) + .where(AIGeneratedStatement.message_id == message_id) + .values(execution_status="failed") + ) + + await db.commit() + + # 返回更新后的原消息(不再创建新消息) + return ChatResponse( + message_id=message.message_id, + content=message.content, + message_type=MessageType.ASSISTANT, + sql_text=sql_text, + sql_type=sql_type, + requires_confirmation=False, + data=None + ) + +async def _handle_ai_service_error_response( + db: AsyncSession, + session_id: int, + error_detail: str +) -> ChatResponse: + """ + 处理AI服务调用错误的响应。 + + 当AI模型不可用、API超时或连接失败时,保存准确的错误消息。 + + Args: + db: 数据库会话 + session_id: 会话ID + error_detail: 错误详情 + """ + # 根据错误类型生成友好的错误消息 + friendly_message = _get_ai_service_friendly_error(error_detail) + + reply_content = f"❌ AI服务错误:{friendly_message}" + ai_message = await crud_message.create_message(db, session_id, reply_content, role="assistant") + await db.commit() + + return ChatResponse( + message_id=ai_message.message_id, + content=reply_content, + message_type=MessageType.ASSISTANT, + sql_text=None, + sql_type="ERROR", + requires_confirmation=False, + data=[{"error": friendly_message}] + ) + + +def _get_ai_service_friendly_error(error_detail: str) -> str: + """ + 将AI服务错误详情转换为用户友好的消息。 + + Args: + error_detail: 原始错误详情 + + Returns: + str: 用户友好的错误消息 + """ + # 处理空错误详情 + if not error_detail or not error_detail.strip(): + return "AI服务暂时不可用,请稍后重试。" + + error_lower = error_detail.lower() + + # 连接/网络错误 + if any(keyword in error_lower for keyword in ["connect", "connection", "network", "unreachable"]): + return "无法连接到AI服务,请检查网络连接或稍后重试。" + + # 超时错误 + if any(keyword in error_lower for keyword in ["timeout", "timed out", "timedout"]): + return "AI服务响应超时,请稍后重试。" + + # 模型不存在/不可用 + if any(keyword in error_lower for keyword in ["model not found", "model_not_found", "invalid model", "no such model"]): + return "所选AI模型当前不可用,请尝试切换其他模型。" + + # 认证/授权错误 + if any(keyword in error_lower for keyword in ["unauthorized", "401", "403", "forbidden", "api key", "authentication"]): + return "AI服务认证失败,请联系管理员检查API配置。" + + # 服务端错误 + if any(keyword in error_lower for keyword in ["500", "502", "503", "504", "internal server", "service unavailable"]): + return "AI服务暂时不可用,请稍后重试。" + + # 请求限制 + if any(keyword in error_lower for keyword in ["rate limit", "too many requests", "429"]): + return "请求过于频繁,请稍后重试。" + + # 默认消息 + return f"AI服务调用失败:{error_detail[:100]}{'...' if len(error_detail) > 100 else ''}" + + +async def _handle_meta_sql_response(db: AsyncSession, session_id: int) -> ChatResponse: + """处理元数据/错误 SQL 的响应。""" + reply_content = "抱歉,我无法执行该操作或理解您的指令。请提供具体的业务需求(如:查询书籍)。" + ai_message = await crud_message.create_message(db, session_id, reply_content, role="assistant") + await db.commit() + + return ChatResponse( + message_id=ai_message.message_id, + content=reply_content, + message_type=MessageType.ASSISTANT, + sql_text=None, + sql_type="ERROR_FEEDBACK", + requires_confirmation=False, + data=None + ) + + +async def _try_execute_sql( + db: AsyncSession, + sql_text: str, + sql_type: str, + instance_id: int, + user_id: int +) -> Tuple[List[Dict], str]: + """尝试执行 SQL 并返回结果和状态。""" + try: + database_instance = await crud_database_instance.get(db, instance_id) + exec_type = sql_type if sql_type != "UNKNOWN" else "SELECT" + raw_result = await _execute_sql_by_type(sql_text, exec_type, database_instance, user_id) + + if isinstance(raw_result, list): + data = raw_result + elif isinstance(raw_result, dict): + data = [raw_result] + else: + data = [] + + return data, "success" + except Exception as e: + log.error("SQL Execution Error: {}", str(e)) + # 返回错误信息,便于后续处理 + error_msg = str(e) + if hasattr(e, 'detail') and e.detail: + error_msg = str(e.detail) + friendly_msg = _get_friendly_error_message(error_msg) + return [{"error": friendly_msg}], "failed" + + +# ========================================================= +# 8. 确认执行逻辑 +# ========================================================= + +async def _get_message_context( + db: AsyncSession, + message_id: int +) -> Tuple[MessageModel, SessionModel, ProjectModel]: + """获取消息相关的上下文对象。""" + stmt = select(MessageModel).where(MessageModel.message_id == message_id) + result = await db.execute(stmt) + message = result.scalar_one_or_none() + if not message: + raise ItemNotFoundException(message="消息未找到") + + stmt_session = select(SessionModel).where(SessionModel.session_id == message.session_id) + result_session = await db.execute(stmt_session) + session_obj = result_session.scalar_one_or_none() + if not session_obj: + raise ItemNotFoundException(message="Session not found") + + stmt_project = select(ProjectModel).where(ProjectModel.project_id == session_obj.project_id) + result_project = await db.execute(stmt_project) + project = result_project.scalar_one_or_none() + if not project: + raise ItemNotFoundException(message="项目未找到") + + return message, session_obj, project + + +def _validate_confirmation(message: MessageModel, project: ProjectModel, user_id: int) -> None: + """验证确认操作的合法性。""" + if project.user_id != user_id: + raise ForbiddenException(message="访问拒绝") + if not message.requires_confirmation: + raise InvalidOperationException(message="此消息不需要确认") + if message.user_confirmed: + raise InvalidOperationException(message="已确认/执行") + + +def _extract_sql_from_content(content: str) -> str: + """从消息内容中提取 SQL。""" + sql_text = "" + if ":\n" in content: + sql_text = content.split(":\n")[-1].strip() + else: + sql_text = content.strip() + return sql_text.replace("```sql", "").replace("```", "").strip() + + +async def _update_statement_result( + db: AsyncSession, + message_id: int, + execute_res: List[Dict] +) -> None: + """更新 SQL 语句的执行结果。""" + stmt_query = select(AIGeneratedStatement).where(AIGeneratedStatement.message_id == message_id) + stmt_res = await db.execute(stmt_query) + db_stmt = stmt_res.scalar_one_or_none() + + if db_stmt: + db_stmt.execution_result = execute_res + db_stmt.execution_status = "success" + db.add(db_stmt) + + +async def _handle_execution_error( + db: AsyncSession, + original_message_id: int, + error: Exception +) -> ChatResponse: + """处理执行错误并返回错误响应,同时更新数据库状态。""" + # 优先获取 detail,因为 BusinessException/AppException 的 message 可能是类属性默认值"内部错误" + error_msg = str(error) + if hasattr(error, 'detail') and error.detail: + error_msg = str(error.detail) + elif hasattr(error, 'message') and error.message and error.message != "内部错误": + error_msg = str(error.message) + + # 提供更友好的错误信息 + friendly_msg = _get_friendly_error_message(error_msg) + content = f"❌ 执行失败:\n{friendly_msg}" + + # 获取原消息并更新状态 + stmt = select(MessageModel).where(MessageModel.message_id == original_message_id) + result = await db.execute(stmt) + message = result.scalar_one_or_none() + + sql_text = None + session_id = None + if message: + # 提取SQL文本用于返回(在更新内容之前) + sql_text = _extract_sql_from_content(message.content) + session_id = message.session_id + + # 标记消息为已确认(执行过了,虽然失败),直接更新为错误内容 + message.user_confirmed = True + message.content = content # 直接替换为错误信息,保持刷新后显示一致 + db.add(message) + + # 更新关联的 AIGeneratedStatement 状态为 failed + await db.execute( + update(AIGeneratedStatement) + .where(AIGeneratedStatement.message_id == original_message_id) + .values( + execution_status="failed", + execution_result=[{"error": friendly_msg}] + ) + ) + + await db.commit() + + # 索引错误案例到向量数据库,帮助AI避免类似错误 + if session_id and sql_text: + try: + import asyncio + index_content = f"错误SQL案例:\nSQL语句:{sql_text}\n执行错误:{friendly_msg}" + asyncio.create_task( + index_new_message( + session_id=session_id, + message_id=original_message_id, + content=index_content, + role="assistant" + ) + ) + except Exception as e: + log.warning(f"Failed to index error case: {e}") + + return ChatResponse( + message_id=original_message_id, # 使用原始消息ID + content=content, + message_type=MessageType.ASSISTANT, + sql_text=sql_text, + sql_type="ERROR", + requires_confirmation=False, + data=[{"error": friendly_msg}] + ) + + +def _get_friendly_error_message(error_msg: str) -> str: + """将技术错误信息转换为用户友好的错误信息。""" + error_lower = error_msg.lower() + + # 常见数据库错误的友好提示 + if "duplicate entry" in error_lower or "unique constraint" in error_lower: + return "数据重复(违反唯一约束)。\n详细信息: " + error_msg + elif "doesn't have a default value" in error_lower: + return "必填字段缺失(字段没有默认值且未提供值,可能是主键未设置AUTO_INCREMENT)。\n详细信息: " + error_msg + elif "cannot be null" in error_lower: + return "字段不能为空。\n详细信息: " + error_msg + elif "can't specify target table" in error_lower and "update in from clause" in error_lower: + return "MySQL限制:INSERT/UPDATE语句中不能直接从同一张表SELECT。\n详细信息: " + error_msg + elif "foreign key constraint" in error_lower: + return "外键约束错误,请检查关联数据是否存在。\n详细信息: " + error_msg + elif "table doesn't exist" in error_lower or "no such table" in error_lower: + return "表不存在,请检查表名是否正确。\n详细信息: " + error_msg + elif ("column" in error_lower and "doesn't exist" in error_lower) or "unknown column" in error_lower: + return "列不存在,请检查列名是否正确。\n详细信息: " + error_msg + elif "syntax error" in error_lower: + return "SQL语法错误,请检查SQL语句。\n详细信息: " + error_msg + elif "access denied" in error_lower or "permission denied" in error_lower: + return "权限不足,无法执行此操作。\n详细信息: " + error_msg + elif "data too long" in error_lower: + return "数据过长,超出字段长度限制。\n详细信息: " + error_msg + elif "incorrect" in error_lower and "value" in error_lower: + return "数据类型或格式不正确。\n详细信息: " + error_msg + else: + # 对于未枚举的错误,仍然返回原始错误信息,而不是"内部错误" + return "执行失败。\n详细信息: " + error_msg + + +async def confirm_and_execute_sql( + db: AsyncSession, + message_id: int, + user_id: int +) -> ChatResponse: + """ + 用户确认执行某条消息中的 SQL (通常是增删改操作)。 + """ + # 获取上下文 + message, session_obj, project = await _get_message_context(db, message_id) + + # 验证 + _validate_confirmation(message, project, user_id) + + # 提取 SQL + sql_text = _extract_sql_from_content(message.content) + + # 执行 + try: + database_instance = await crud_database_instance.get(db, project.instance_id) + result = await _execute_sql_by_type(sql_text, "UPDATE", database_instance, user_id) + execute_res = [result] if isinstance(result, dict) else result + + message.user_confirmed = True + db.add(message) + + await _update_statement_result(db, message_id, execute_res) + await db.commit() + + log.info("User {} executed DML and persisted results.", user_id) + + except Exception as e: + log.error("Execution failed: {}", e) + return await _handle_execution_error(db, message_id, e) + + return ChatResponse( + message_id=message.message_id, + content=message.content, + message_type=MessageType.ASSISTANT, + sql_text=sql_text, + sql_type="DML_EXECUTED", + requires_confirmation=False, + data=execute_res + ) diff --git a/src/backend/app/service/chat_service_test.py b/src/backend/app/service/chat_service_test.py new file mode 100644 index 0000000..f6e186b --- /dev/null +++ b/src/backend/app/service/chat_service_test.py @@ -0,0 +1,251 @@ +""" +聊天服务(测试变体)。 + +用于调试/验证 chat-to-SQL 流程,连接可配置的 AI 端点并返回基础解析结果。 +""" + +# backend/app/service/chat_service_test.py + +import json +import httpx +import sqlparse +from core.exceptions import ItemNotFoundException +from sqlalchemy.future import select +from sqlalchemy.ext.asyncio import AsyncSession + +from crud.crud_message import crud_message +from crud.crud_project import crud_project +from crud.crud_knowledge import crud_knowledge +from models.session import Session as SessionModel +from schema.chat import ChatResponse, MessageType +from core.log import log + + +# ----------------------- +# AI 配置 +# ----------------------- +AI_SERVICE_URL = "http://26.64.77.145:1234/v1/chat/completions" +AI_MODEL = "codellama/CodeLlama-13b-Instruct-hf" +AI_API_KEY = "sk-YQjmNgkBJqRTsZCsr7r0zkHoLb6G0exL9u8gEkJTf5oZQXmE" + + +# ----------------------- +# 获取 Session → Project +# ----------------------- +async def get_project_id_by_session(db: AsyncSession, session_id: int) -> int: + """ + 根据会话 ID 获取项目 ID。 + + Args: + db (AsyncSession): 数据库会话。 + session_id (int): 会话 ID。 + + Returns: + int: 项目 ID。 + + Raises: + HTTPException: 当会话不存在时。 + """ + result = await db.execute( + select(SessionModel).where(SessionModel.session_id == session_id) + ) + session = result.scalar_one_or_none() + + if not session: + raise ItemNotFoundException(message="Session not found") + + return session.project_id + + +# ----------------------- +# 获取 Schema +# ----------------------- +async def get_project_schema(db: AsyncSession, project_id: int) -> str: + """ + 获取项目 Schema 的 JSON 文本。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目 ID。 + + Returns: + str: Schema 的 JSON 字符串或占位文本。 + """ + project = await crud_project.get(db, project_id) + + if not project or not project.schema_definition: + return "No schema defined." + + sd = project.schema_definition + return json.dumps(sd, ensure_ascii=False, indent=2) if isinstance(sd, (dict, list)) else str(sd) + + +# ----------------------- +# 获取术语库 +# ----------------------- +async def get_domain_knowledge(db: AsyncSession, project_id: int) -> str: + """ + 获取项目的术语库并格式化为提示文本。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目 ID。 + + Returns: + str: 术语库提示文本,可能为空。 + """ + items = await crud_knowledge.get_by_project(db, project_id) + + if not items: + return "" + + txt = "\n[业务术语]\n" + for t in items: + txt += f"- {t.term}: {t.definition}\n" + + return txt + + +# ----------------------- +# 调用 Claude Agent +# ----------------------- +async def call_ai_agent( + schema: str, + glossary: str, + question: str, +) -> dict: + """ + 调用外部 AI 服务,要求返回包含 `sql` 字段的 JSON。 + + Args: + schema (str): Schema 文本。 + glossary (str): 术语库文本。 + question (str): 用户问题。 + + Returns: + dict: 解析后的 JSON,至少包含 `sql` 键。 + """ + system_prompt = f""" +你是 SQL 专家,请根据 Schema 与 业务术语生成 SQL。 + +[Schema] +{schema} + +[Domain Knowledge] +{glossary} + +要求: +1. 必须返回 JSON +2. JSON 必须包含字段 "sql" +""" + + payload = { + "model": AI_MODEL, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": question} + ], + "temperature": 0.1, + "stream": False # 添加 stream 参数 + } + + headers = { + "Authorization": f"Bearer {AI_API_KEY}", + "Content-Type": "application/json" + } + + try: + log.info(f"Payload: {payload}") + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(AI_SERVICE_URL, json=payload, headers=headers) + + + resp.raise_for_status() + raw = resp.json() + + log.info(f"Raw Response: {raw}") + + # 更健壮的响应解析 + if "choices" in raw and len(raw["choices"]) > 0: + content = raw["choices"][0]["message"]["content"] + else: + # 尝试其他可能的响应格式 + content = raw.get("message", {}).get("content", "") or raw.get("content", "") + + # 清理内容 + clean = content.replace("```json", "").replace("```", "").strip() + + # 尝试解析 JSON + try: + return json.loads(clean) + except json.JSONDecodeError: + # 如果不是 JSON,返回原始内容 + return {"sql": clean} + + except Exception as e: + print("AI Error:", e) + return {"sql": f"-- AI Error: {str(e)}"} + + +# ----------------------- +# 主流程(修改版) +# ----------------------- +async def process_chat( + db: AsyncSession, + session_id: int, + user_input: str, + user_id: int, +) -> ChatResponse: + """ + 主流程:存储消息、调用 AI、识别 SQL 类型并返回响应。 + + Args: + db (AsyncSession): 数据库会话。 + session_id (int): 会话 ID。 + user_input (str): 用户输入文本。 + user_id (int): 用户 ID。 + + Returns: + ChatResponse: 包含 SQL 文本与类型的响应。 + """ + + # 1. 存用户消息 + user_msg = await crud_message.create_message( + db, session_id, user_input, role="user" + ) + log.info(f"User message stored: {user_msg}") + + # 2. 获取上下文 + project_id = await get_project_id_by_session(db, session_id) + schema = await get_project_schema(db, project_id) + glossary = await get_domain_knowledge(db, project_id) + log.info(f"Schema: {schema}") + log.info(f"Glossary: {glossary}") + + # 3. 模型生成 SQL + ai_json = await call_ai_agent(schema, glossary, user_input) + sql_text = ai_json.get("sql", "-- no sql") + + # 4. SQL 类型判断 + try: + parsed = sqlparse.parse(sql_text) + sql_type = parsed[0].get_type().upper() if parsed else "UNKNOWN" + except: + sql_type = "UNKNOWN" + + # 5. 创建 AI 回复消息 + reply_content = f"生成 SQL 类型:{sql_type}" + ai_message = await crud_message.create_message( + db, session_id, reply_content, role="assistant" + ) + + # 6. 返回 AI 回复给前端 + return ChatResponse( + message_id=ai_message.message_id, + content=reply_content, # AI 回复的文本内容 + message_type=MessageType.ASSISTANT, # 标记为 AI 消息 + sql_text=sql_text, + sql_type=sql_type, + requires_confirmation=sql_type in ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER"], + data=None + ) diff --git a/src/backend/app/service/db_executor_service.py b/src/backend/app/service/db_executor_service.py new file mode 100644 index 0000000..b54074c --- /dev/null +++ b/src/backend/app/service/db_executor_service.py @@ -0,0 +1,42 @@ +# backend/app/service/db_executor_service.py + +from core.config import config +from core.sql_sort import sort_ddl_by_dependency +from core.exceptions import ValidationException +from mysql.mysql_execute import deploy_mysql_ddl +from core.log import log +from postgresql.postgres_execute import deploy_postgres_ddl +from sqlite.sqlite_execute import deploy_sqlite_ddl + +class DBExecutorService: + """ + 数据库部署调度服务。 + 支持未来扩展 PostgreSQL, SQLite 等。 + """ + + @classmethod + async def deploy(cls, db_type: str, db_name: str, ddl: str, use_smart_parse: bool, user_id: int): + # 1. 预处理:清洗 DDL(去除注释) + cleaned_ddl = "\n".join([l for l in ddl.splitlines() if not l.strip().startswith('--')]) + + # 2. 获取执行语句序列 + if use_smart_parse: + try: + # 拓扑排序并处理依赖(目前该函数已集成方言转换) + execution_statements = sort_ddl_by_dependency(cleaned_ddl, dialect=db_type) + except Exception as e: + raise ValidationException(f"SQL 解析或排序失败: {str(e)}") + else: + execution_statements = [s.strip() for s in cleaned_ddl.split(';') if s.strip()] + + # 3. 根据类型分发执行 (策略模式雏形) + if db_type == 'mysql': + await deploy_mysql_ddl(db_name, execution_statements) + elif db_type == 'postgresql': + await deploy_postgres_ddl(db_name, execution_statements) + # raise NotImplementedError("PostgreSQL support coming soon") + elif db_type == 'sqlite': + await deploy_sqlite_ddl(db_name, execution_statements, config.sqlite, user_id) + # raise NotImplementedError("SQLite support coming soon") + else: + raise ValidationException(f"Unsupported database type: {db_type}") \ No newline at end of file diff --git a/src/backend/app/service/ddl_generator.py b/src/backend/app/service/ddl_generator.py new file mode 100644 index 0000000..44aac52 --- /dev/null +++ b/src/backend/app/service/ddl_generator.py @@ -0,0 +1,19 @@ +""" +DDL 生成脚本。 + +用于根据需求与逻辑 Schema 调用远端服务生成 DDL,适用于工具链与集成测试。 +""" + +# backend/app/service/ddl_generator.py + +import requests + +url = "https://schema2ddl.strangeloop.fun/generate/ddl" +data = { + "database_requirment": "A university needs a student course selection management system to maintain and track students' course selection information. Students have\ninformation such as student ID, name, age, the name of the course chosen by the student, etc. Each student can take multiple courses and can drop or\nchange courses within the specified time. Each course has information such as course number, course name, credits, lecturer and class time. The\npopularity of a course depends on the number of students who take the course. The system can predict the popularity of the course and provide support\nfor academic decision-making", + "schema": "(1) Student\n- Attribute: student ID, name, age\n- Primary Key: student ID\n\n(2) Course\n- Attribute: course number, course name, credits, lecturer, class time\n- Primary Key: course number\n\n(3) StudentCourses\n- Attribute: student ID, course number\n- Primary Key: student ID, course number\n- Foreign Key: student ID (reference Student: student ID), course number (reference Course: course number)\n ", + "target_db_type": "mysql", + "model": "gpt4" +} +response = requests.post(url, json=data) +print(response.json()) diff --git a/src/backend/app/service/email_service.py b/src/backend/app/service/email_service.py new file mode 100644 index 0000000..fafa84c --- /dev/null +++ b/src/backend/app/service/email_service.py @@ -0,0 +1,49 @@ +""" +邮件服务。 + +发送并验证邮箱验证码,用于邮箱更新与注册流程;依赖核心邮件工具与异常定义。 +""" + +# backend/app/service/email_service.py + + +from core.log import log +from core.email_utils import send_verify_email, verify_code +from core import exceptions + +async def service_send_verification_code(email: str) -> None: + """ + 向指定邮箱发送验证码。 + + Args: + email (str): 接收验证码的邮箱地址。 + + Raises: + exceptions.SendVerificationCodeFailedException: 发送失败。 + """ + result = await send_verify_email(email) + + if not result: + log.error(f"无法发送验证码到 {email}") + raise exceptions.SendVerificationCodeFailedException() + +async def service_verify_code(email: str, code: str) -> bool: + """ + 验证邮箱和验证码。 + + Args: + email (str): 邮箱地址。 + code (str): 验证码。 + + Returns: + bool: 验证结果,True 表示验证通过。 + + Raises: + exceptions.CodeInvalidException: 验证失败。 + """ + result = await verify_code(email, code) + if result: + log.info(f"Verified verification code {code} for {email}") + return True + log.error(f"验证码 {code} 对邮箱 {email} 无效") + raise exceptions.CodeInvalidException() diff --git a/src/backend/app/service/embedding_service.py b/src/backend/app/service/embedding_service.py new file mode 100644 index 0000000..b4d89da --- /dev/null +++ b/src/backend/app/service/embedding_service.py @@ -0,0 +1,172 @@ +""" +Embedding 服务模块。 + +负责调用阿里云百炼的 text-embedding-v4 模型生成文本向量。 +支持单文本和批量文本的向量化。 + +使用方式: + from service.embedding_service import embedding_service + + # 单文本向量化 + vector = await embedding_service.embed_text("你的文本") + + # 批量向量化 + vectors = await embedding_service.embed_texts(["文本1", "文本2"]) +""" + +import asyncio +from typing import List, Optional +from openai import AsyncOpenAI +from core.log import log +from core.config import settings + + +class EmbeddingService: + """ + Embedding 服务类。 + + 使用阿里云百炼的 text-embedding-v4 模型。 + 支持自定义向量维度(默认1024)。 + """ + + def __init__( + self, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + model: Optional[str] = None, + dimensions: Optional[int] = None + ): + """ + 初始化 Embedding 服务。 + + Args: + api_key: 阿里云百炼 API Key + base_url: API 基础 URL + model: 模型名称 + dimensions: 向量维度(text-embedding-v4 支持 256-2048) + """ + # 从配置读取(必须配置) + embedding_config = getattr(settings, 'embedding', None) + if not embedding_config: + raise ValueError("Embedding 配置未找到,请检查 config.yaml 中的 embedding 配置") + + self.api_key = api_key or embedding_config.api_key + self.base_url = base_url or embedding_config.base_url + self.model = model or embedding_config.model + self.dimensions = dimensions or embedding_config.dimensions + + self.client = AsyncOpenAI( + api_key=self.api_key, + base_url=self.base_url + ) + + async def embed_text(self, text: str) -> List[float]: + """ + 将单个文本转换为向量。 + + Args: + text: 输入文本 + + Returns: + List[float]: 文本的向量表示 + """ + try: + response = await self.client.embeddings.create( + model=self.model, + input=text, + dimensions=self.dimensions, + encoding_format="float" + ) + return response.data[0].embedding + except Exception as e: + log.error(f"Embedding generation failed: {e}") + raise + + async def embed_texts(self, texts: List[str]) -> List[List[float]]: + """ + 批量将文本转换为向量。 + + Args: + texts: 输入文本列表 + + Returns: + List[List[float]]: 向量列表,顺序与输入文本对应 + """ + if not texts: + return [] + + try: + # 阿里云百炼支持批量请求,最多一次处理25条 + batch_size = 25 + all_embeddings = [] + + for i in range(0, len(texts), batch_size): + batch = texts[i:i + batch_size] + response = await self.client.embeddings.create( + model=self.model, + input=batch, + dimensions=self.dimensions, + encoding_format="float" + ) + # 按索引排序,确保顺序正确 + batch_embeddings = sorted(response.data, key=lambda x: x.index) + all_embeddings.extend([item.embedding for item in batch_embeddings]) + + return all_embeddings + except Exception as e: + log.error(f"Batch embedding generation failed: {e}") + raise + + async def embed_with_cache_key(self, text: str, prefix: str = "") -> tuple: + """ + 生成文本向量并返回缓存键。 + + Args: + text: 输入文本 + prefix: 缓存键前缀 + + Returns: + tuple: (cache_key, embedding) + """ + import hashlib + + # 生成文本的哈希作为缓存键 + text_hash = hashlib.md5(text.encode('utf-8')).hexdigest()[:16] + cache_key = f"{prefix}:{text_hash}" if prefix else text_hash + + embedding = await self.embed_text(text) + return cache_key, embedding + + +# 全局单例实例 +embedding_service = EmbeddingService() + + +# ========================================================= +# 便捷函数 +# ========================================================= + +async def get_embedding(text: str) -> List[float]: + """ + 便捷函数:获取单个文本的向量。 + + Args: + text: 输入文本 + + Returns: + List[float]: 向量 + """ + return await embedding_service.embed_text(text) + + +async def get_embeddings(texts: List[str]) -> List[List[float]]: + """ + 便捷函数:批量获取文本向量。 + + Args: + texts: 文本列表 + + Returns: + List[List[float]]: 向量列表 + """ + return await embedding_service.embed_texts(texts) diff --git a/src/backend/app/service/knowledge_service.py b/src/backend/app/service/knowledge_service.py new file mode 100644 index 0000000..e91885b --- /dev/null +++ b/src/backend/app/service/knowledge_service.py @@ -0,0 +1,370 @@ +""" +术语服务。 + +管理项目的领域术语,包括增删改查、导入与分页;负责权限校验、数据校验 +与错误处理。 +""" + +# backend/app/service/knowledge_service.py + +import pandas as pd +from typing import List, Optional +from fastapi import UploadFile +from sqlalchemy.ext.asyncio import AsyncSession as Session +import io + +# 隐式绝对导入 +from crud.crud_knowledge import crud_knowledge +from crud.crud_project import crud_project # 用于检查项目权限 +from schema import knowledge as schemas +from core.exceptions import ItemNotFoundException, ValidationException, \ + OperationNotPermittedException +from core.log import log + +# RAG 服务导入 +from service.rag_service import rag_service + +# ---------------------------------------------------------------------- +# 术语创建 +# ---------------------------------------------------------------------- +async def create_knowledge_service( + db: Session, + project_id: int, + user_id: int, + data: schemas.KnowledgeCreate +) -> schemas.KnowledgeResponse: + """ + 创建新术语,并检查权限与唯一性。 + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + user_id (int): 用户 ID。 + data (schemas.KnowledgeCreate): 术语创建数据。 + + Returns: + schemas.KnowledgeResponse: 创建后的术语信息。 + + Raises: + ItemNotFoundException: 项目不存在或无权限。 + ValidationException: 术语已存在。 + """ + # 1. 检查项目权限 + project = await crud_project.get(db, project_id) + if not project or project.user_id != user_id: + raise ItemNotFoundException("项目未找到或访问被拒绝") + + # 2. 检查术语是否已存在 + if await crud_knowledge.check_term_exists(db, project_id, data.term): + raise ValidationException(f"术语 '{data.term}' 在此项目中已存在") + + # 3. 创建 + new_term = await crud_knowledge.create(db, project_id=project_id, **data.model_dump()) + + # 4. 异步触发向量索引(不阻塞主流程) + try: + import asyncio + asyncio.create_task( + rag_service.index_knowledge( + project_id=project_id, + knowledge_id=new_term.knowledge_id, + term=new_term.term, + definition=new_term.definition + ) + ) + except Exception as e: + log.warning(f"Failed to trigger knowledge indexing: {e}") + + return schemas.KnowledgeResponse.model_validate(new_term) + +# ---------------------------------------------------------------------- +# 术语更新 +# ---------------------------------------------------------------------- +async def update_knowledge_service( + db: Session, + project_id: int, + knowledge_id: int, + user_id: int, + update_data: schemas.KnowledgeUpdate # 建议使用 Update 模型,而不是 Create +) -> schemas.KnowledgeResponse: + """ + 更新术语内容,验证项目归属与权限。 + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + knowledge_id (int): 术语 ID。 + user_id (int): 用户 ID。 + update_data (schemas.KnowledgeUpdate): 更新数据。 + + Returns: + schemas.KnowledgeResponse: 更新后的术语信息。 + + Raises: + ItemNotFoundException: 术语不存在或不属于该项目。 + OperationNotPermittedException: 无权限。 + """ + # 1. 查数据 + db_obj = await crud_knowledge.get(db, knowledge_id) + if not db_obj: + raise ItemNotFoundException(f"术语 {knowledge_id} 未找到") + + # 2. 安全校验:确保 URL 里的 project_id 和数据库里记录的一致 + # 防止用户在 URL A 项目下,却试图修改 B 项目的术语 + if db_obj.project_id != project_id: + raise ItemNotFoundException("术语不属于此项目") + + # 3. 查权限 (检查用户是否拥有该项目) + project = await crud_project.get(db, project_id=project_id) + if not project or project.user_id != user_id: + raise OperationNotPermittedException("访问被拒绝") + + # 4. 更新 + updated_obj = await crud_knowledge.update(db, db_obj, update_data.model_dump(exclude_unset=True)) + + # 5. 异步更新向量索引 + try: + import asyncio + asyncio.create_task( + rag_service.index_knowledge( + project_id=project_id, + knowledge_id=updated_obj.knowledge_id, + term=updated_obj.term, + definition=updated_obj.definition + ) + ) + except Exception as e: + log.warning(f"Failed to trigger knowledge re-indexing: {e}") + + return schemas.KnowledgeResponse.model_validate(updated_obj) + + +# ---------------------------------------------------------------------- +# 批量删除术语 +# ---------------------------------------------------------------------- +async def batch_delete_knowledge_service( + db: Session, + project_id: int, + user_id: int, + knowledge_ids: List[int] +) -> int: + """ + 批量删除术语。 + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + user_id (int): 用户 ID。 + knowledge_ids (List[int]): 待删除术语 ID 列表。 + + Returns: + int: 删除成功的数量。 + + Raises: + OperationNotPermittedException: 无权限。 + """ + # 1. 权限检查 + project = await crud_project.get(db, project_id) + if not project or project.user_id != user_id: + raise OperationNotPermittedException("访问被拒绝") + + count = await crud_knowledge.remove_multi(db, project_id, knowledge_ids) + + # 异步删除向量索引 + try: + import asyncio + for k_id in knowledge_ids: + asyncio.create_task( + rag_service.delete_knowledge_item(k_id) + ) + except Exception as e: + log.warning(f"Failed to trigger knowledge vector deletion: {e}") + + return count + + + +# ---------------------------------------------------------------------- +# 术语列表(分页) +# ---------------------------------------------------------------------- +async def get_knowledge_list_service( + db: Session, + project_id: int, + user_id: int, + page: int, + page_size: int, + search: Optional[str] +) -> schemas.PaginatedKnowledgeList: + """ + 获取术语列表(分页)。 + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + user_id (int): 用户 ID。 + page (int): 页码。 + page_size (int): 每页数量。 + search (Optional[str]): 搜索关键字。 + + Returns: + schemas.PaginatedKnowledgeList: 分页结果。 + + Raises: + ItemNotFoundException: 项目不存在。 + """ + # 1. 检查项目权限 + project = await crud_project.get(db, project_id) + if not project or project.user_id != user_id: + raise ItemNotFoundException("项目未找到") + + skip = (page - 1) * page_size + total = await crud_knowledge.get_total_count(db, project_id, search) + items = await crud_knowledge.get_by_project(db, project_id, skip, page_size, search) + + return schemas.PaginatedKnowledgeList( + total=total, page=page, page_size=page_size, items=items + ) + + +# ---------------------------------------------------------------------- +# 批量导入术语 +# ---------------------------------------------------------------------- +async def import_knowledge_service( + db: Session, + project_id: int, + user_id: int, + file: UploadFile +) -> schemas.ImportResponse: + """ + 解析 Excel/CSV 文件并批量导入术语。 + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + user_id (int): 用户 ID。 + file (UploadFile): 上传文件。 + + Returns: + schemas.ImportResponse: 导入结果统计。 + + Raises: + ItemNotFoundException: 项目不存在。 + ValidationException: 文件类型或内容解析失败。 + """ + # 1. 权限检查 + project = await crud_project.get(db, project_id) + if not project or project.user_id != user_id: + raise ItemNotFoundException("项目未找到") + + # 2. 文件读取与解析 ---- 改为支持excel和csv + if not file.filename.endswith(('.xlsx', '.xls', '.csv')): + raise ValidationException("仅支持 .xlsx, .xls, .csv 文件") + + # 2. 读取文件 + content = await file.read() + try: + if file.filename.endswith('.csv'): + # [修复] 增加编码自动回退机制 + try: + # 1. 优先尝试标准 UTF-8 + df = pd.read_csv(io.BytesIO(content), encoding='utf-8', index_col=False) + except UnicodeDecodeError: + try: + # 2. 失败则尝试 GB18030 (包含 GBK 和 GB2312 的超集,兼容性最好) + df = pd.read_csv(io.BytesIO(content), encoding='gb18030', index_col=False) + except UnicodeDecodeError: + # 3. 还是不行,尝试 Windows-1252 (西欧常见) + df = pd.read_csv(io.BytesIO(content), encoding='cp1252', index_col=False) + + else: + # Excel 文件是二进制格式,不需要指定 encoding + df = pd.read_excel(io.BytesIO(content)) + + # 重置索引确保索引为数字 + df = df.reset_index(drop=True) + except Exception as e: + raise ValidationException(f"解析文件失败: {str(e)}") + + # 检查文件是否为空或列数不足 + if df.empty: + raise ValidationException("文件格式错误:文件内容为空") + + if len(df.columns) < 2: + raise ValidationException("文件格式错误:至少需要两列数据(术语、定义)") + + # 3. 校验表头 (假设模板列名为 term, definition, examples) + # 支持中文列名,如 "术语", "定义", "示例" + required_cols = ['term', 'definition'] + # 简单的列名映射,兼容中文 + rename_map = {'术语': 'term', '定义': 'definition', '示例': 'examples', '备注': 'examples'} + df.rename(columns=rename_map, inplace=True) + + # 检查是否缺少表头(第一行被当作数据而非列名) + # 如果列名不包含预期的表头,说明可能缺少表头行 + current_cols = set(df.columns) + expected_cols = {'term', 'definition', '术语', '定义'} + if not current_cols.intersection(expected_cols): + raise ValidationException( + "文件格式错误:缺少表头行。请确保第一行为列名,且包含「术语」和「定义」两列" + ) + + if not all(col in df.columns for col in required_cols): + raise ValidationException("文件格式错误:缺少必需的列。请确保文件包含「术语」和「定义」两列") + + # 4. 遍历处理 + success_items = [] + failures = [] + + # 获取现有术语用于去重 + existing_items = await crud_knowledge.get_all_by_project(db, project_id) + existing_terms = {item.term for item in existing_items} + + for index, row in df.iterrows(): + # 安全转换行号,处理非数字索引的情况 + try: + row_num = int(index) + 2 # Excel 行号从 1 开始,表头占 1 行 + except (ValueError, TypeError): + # 如果索引无法转换为整数,说明文件格式可能有问题 + raise ValidationException( + "文件格式错误:数据列解析异常,可能是某行数据中包含了多余的逗号(,)分隔符," + "导致列数不匹配。请检查并修正文件内容。" + ) + try: + # 安全地获取和转换字段值 + term_val = row.get('term', '') + definition_val = row.get('definition', '') + examples_val = row.get('examples', '') + + # 处理可能的 NaN 值和类型转换 + term = '' if pd.isna(term_val) else str(term_val).strip() + definition = '' if pd.isna(definition_val) else str(definition_val).strip() + examples = '' if pd.isna(examples_val) else str(examples_val).strip() + + if not term or not definition: + failures.append(schemas.ImportFailure(row=row_num, error="术语或定义为空")) + continue + + if term in existing_terms: + failures.append(schemas.ImportFailure(row=row_num, error="术语已存在")) + continue + + success_items.append({ + "term": term, + "definition": definition, + "examples": examples + }) + existing_terms.add(term) # 防止文件内重复 + except Exception as e: + failures.append(schemas.ImportFailure(row=row_num, error=f"数据格式错误: {str(e)}")) + continue + + # 5. 批量写入 + imported_count = 0 + if success_items: + imported_count = await crud_knowledge.batch_create(db, project_id, success_items) + + return schemas.ImportResponse( + imported_count=imported_count, + failed_count=len(failures), + failures=failures + ) diff --git a/src/backend/app/service/mysql_service.py b/src/backend/app/service/mysql_service.py new file mode 100644 index 0000000..6c8c8dc --- /dev/null +++ b/src/backend/app/service/mysql_service.py @@ -0,0 +1,475 @@ +""" +MySQL 用户配置服务。 + +为项目创建 MySQL 用户、授予权限,并优先使用 `sha256_password`,必要时回退 +`mysql_native_password`;同时初始化用户引擎。 +""" +# backend/app/service/mysql_service.py +from sqlalchemy import text +from typing import Optional +from sqlalchemy.exc import IntegrityError, ProgrammingError, OperationalError, SQLAlchemyError + +from sqlalchemy import URL + +from core.config import config +from core.log import log +from core.exceptions import DatabaseOperationFailedException, InvalidOperationException +from mysql.mysql_database import MysqlHelper +from mysql.mysql_execute import execute_sql_root, execute_dml_user, execute_dql_user +from mysql.mysql_secure import validate_safe_sql +from models.database_instance import DatabaseInstance + + +def _escape_sql_identifier(identifier: str) -> str: + """ + 转义 SQL 标识符(用户名、数据库名等) + 使用反引号包裹并转义内部的反引号 + """ + # 转义反引号 + escaped = identifier.replace('`', '``') + return f"`{escaped}`" + + +def _escape_sql_string(value: str) -> str: + """ + 转义 SQL 字符串值 + 使用单引号包裹并转义内部的单引号 + """ + # 转义单引号 + escaped = value.replace("'", "''") + return f"'{escaped}'" + + +async def _execute_raw_sql(sql: str) -> None: + """ + 直接执行原始 SQL,不经过任何转换器 + 专门用于 CREATE USER、GRANT、ALTER USER 等语句 + """ + # 验证 SQL 安全性 + validate_safe_sql(sql, is_root=True) + + # 直接获取引擎并执行 + # 使用 raw=True 参数避免 SQLAlchemy 处理百分号 + engine = await MysqlHelper.get_root_engine() + async with engine.connect() as conn: + # 使用 text() 但设置 bindparams 避免百分号被转义 + # 或者直接使用字符串执行 + await conn.execute(text(sql).execution_options(autocommit=True)) + await conn.commit() + + +async def create_mysql_user_with_plugin( + db_username: str, + db_password: str, + plugin: str = "sha256_password" +) -> None: + """ + 创建 MySQL 用户并指定认证插件。 + + Args: + db_username (str): 用户名。 + db_password (str): 明文密码。 + plugin (str): 认证插件,可选 "sha256_password" 或 "mysql_native_password"。 + + Raises: + DatabaseOperationFailedException: 创建用户失败时抛出异常。 + """ + for user_host in ["%", "localhost"]: + # 使用字符串拼接但确保安全转义 + # CREATE USER 语句不支持参数化查询,必须使用字符串拼接 + escaped_username = _escape_sql_string(db_username) + escaped_host = _escape_sql_string(user_host) + escaped_password = _escape_sql_string(db_password) + create_sql = ( + f"CREATE USER '{escaped_username[1:-1]}'@'{escaped_host[1:-1]}' " + f"IDENTIFIED WITH {plugin} BY {escaped_password}" + ) + log.info("[MySQL] Executing SQL: {}", create_sql) + # 使用直接执行函数,绕过所有转换器 + await _execute_raw_sql(create_sql) + log.info("[MySQL] User {}@{} created with {}", db_username, user_host, plugin) + + +async def grant_user_privileges( + db_name: str, + db_username: str +) -> None: + """ + 授予用户对指定数据库的所有权限。 + + Args: + db_name (str): 数据库名称。 + db_username (str): 用户名。 + + Raises: + DatabaseOperationFailedException: 授予权限失败时抛出异常。 + """ + for user_host in ["%", "localhost"]: + # 使用字符串拼接但确保安全转义 + escaped_db_name = _escape_sql_identifier(db_name) + escaped_username = _escape_sql_string(db_username) + escaped_host = _escape_sql_string(user_host) + grant_sql = f"GRANT ALL PRIVILEGES ON {escaped_db_name}.* TO '{escaped_username[1:-1]}'@'{escaped_host[1:-1]}'" + log.info("[MySQL] Executing SQL: {}", grant_sql) + # 使用直接执行函数,绕过所有转换器 + await _execute_raw_sql(grant_sql) + log.info("[MySQL] Privileges granted to {}@{} on {}", db_username, user_host, db_name) + + +async def flush_privileges() -> None: + """ + 刷新 MySQL 权限。 + + Raises: + DatabaseOperationFailedException: 刷新权限失败时抛出异常。 + """ + try: + await execute_sql_root("FLUSH PRIVILEGES") + log.info("[MySQL] Privileges flushed successfully") + except Exception as flush_error: + log.warning("[MySQL] FLUSH PRIVILEGES failed: {}", str(flush_error)) + + +async def alter_user_plugin( + db_username: str, + db_password: str, + plugin: str = "mysql_native_password" +) -> None: + """ + 修改用户认证插件。 + + Args: + db_username (str): 用户名。 + db_password (str): 明文密码。 + plugin (str): 新的认证插件。 + + Raises: + DatabaseOperationFailedException: 修改插件失败时抛出异常。 + """ + for user_host in ["%", "localhost"]: + # 使用字符串拼接但确保安全转义 + escaped_username = _escape_sql_string(db_username) + escaped_host = _escape_sql_string(user_host) + escaped_password = _escape_sql_string(db_password) + alter_sql = ( + f"ALTER USER '{escaped_username[1:-1]}'@'{escaped_host[1:-1]}' " + f"IDENTIFIED WITH {plugin} BY {escaped_password}" + ) + log.info("[MySQL] Executing SQL: {}", alter_sql) + # 使用直接执行函数,绕过所有转换器 + await _execute_raw_sql(alter_sql) + log.info("[MySQL] Changed plugin to {} for {}@{}", plugin, db_username, user_host) + + +def build_mysql_url( + db_username: str, + db_password: str, + db_name: str, + plugin: str = "sha256_password", + host: str = "localhost", + port: int = 3306 +) -> URL: + """ + 构建 MySQL 连接 URL。 + + Args: + db_username (str): 用户名。 + db_password (str): 明文密码。 + db_name (str): 数据库名称。 + plugin (str): 认证插件。 + host (str): 主机地址。 + port (int): 端口号。 + + Returns: + URL: SQLAlchemy URL 对象。 + """ + from urllib.parse import quote + encoded_password = quote(db_password, safe='') + return URL.create( + drivername="mysql+aiomysql", + username=db_username, + password=encoded_password, + host=host, + port=port, + database=db_name, + query={ + "auth_plugin": plugin, + "charset": "utf8mb4" + } + ) + + +async def init_user_engine_with_plugin( + db_username: str, + db_password: str, + db_name: str, + instance_id: int, + plugin: str = "sha256_password" +) -> bool: + """ + 使用指定插件初始化用户引擎。 + + Args: + db_username (str): 用户名。 + db_password (str): 明文密码。 + db_name (str): 数据库名称。 + instance_id (int): 关联实例 ID。 + plugin (str): 认证插件。 + + Returns: + bool: 初始化成功返回 True,否则返回 False。 + """ + try: + mysql_url = build_mysql_url( + db_username, + db_password, + db_name, + plugin, + host=config.mysql.host, + port=config.mysql.port + ) + await MysqlHelper.init_user_engine(config.mysql, instance_id, mysql_url) + log.info("[MySQL] User engine initialized successfully with {}", plugin) + return True + except Exception as engine_error: + log.warning("[MySQL] Failed to initialize user engine with {}: {}", plugin, str(engine_error)) + return False + + +async def create_mysql_user( + db_name: str, + db_username: str, + db_password: str, + instance_id: int +) -> None: + """ + 创建 MySQL 用户并优先使用 `sha256_password` 插件。 + + 在失败时自动回退到 `mysql_native_password`,同时完成权限授予与用户引擎初始化。 + + Args: + db_name (str): 数据库名称。 + db_username (str): 用户名。 + db_password (str): 明文密码(用于初始化)。 + instance_id (int): 关联实例 ID。 + + Raises: + DatabaseOperationFailedException: 创建或初始化过程中出现的错误会向上抛出。 + """ + try: + # 1. 创建用户 + await create_mysql_user_with_plugin(db_username, db_password, "sha256_password") + + # 2. 授予权限 + await grant_user_privileges(db_name, db_username) + + # 3. 刷新权限 + await flush_privileges() + log.info("[MySQL] User setup completed for {}", db_username) + + # 4. 尝试使用 sha256_password 初始化用户引擎 + if not await init_user_engine_with_plugin(db_username, db_password, db_name, instance_id, "sha256_password"): + # 5. 如果失败,回退到 mysql_native_password + log.info("[MySQL] Falling back to mysql_native_password") + + # 修改用户插件 + await alter_user_plugin(db_username, db_password, "mysql_native_password") + await flush_privileges() + + # 重新尝试初始化 + if not await init_user_engine_with_plugin(db_username, db_password, db_name, instance_id, "mysql_native_password"): + raise DatabaseOperationFailedException(operation="使用sha256_password和mysql_native_password都无法初始化用户引擎") + + except Exception as e: + log.error("[MySQL] Failed to create user: {}", str(e), exc_info=True) + raise + + +async def ensure_user_and_engine( + db_name: str, + db_username: str, + db_password: str, + instance_id: int +) -> bool: + """ + 确保用户存在且引擎已初始化。 + + 执行 SQL 逻辑前的检查函数: + 1. 检查 MysqlHelper 中的 _user_engine 是否有对应项目的引擎 + 2. 如果没有,检查 _user_exist 中是否有这个用户 + 3. 如果没有,去 MySQL 中查询是否创建有该用户 + 4. 如果没有,在 MySQL 中创建用户,并加入 _user_exist 中 + + Args: + db_name (str): 数据库名称。 + db_username (str): 用户名。 + db_password (str): 明文密码。 + instance_id (int): 关联实例 ID。 + + Returns: + bool: 如果用户和引擎都已准备好返回 True,否则返回 False。 + + Raises: + DatabaseOperationFailedException: 检查或创建过程中出现的错误会向上抛出。 + """ + try: + # 1. 检查用户引擎是否存在 + if MysqlHelper.is_user_engine_exists(instance_id): + log.info("[MySQL] User engine already exists for instance {}", instance_id) + return True + + log.info("[MySQL] User engine not found for instance {}, checking user status...", instance_id) + + # 2. 检查 _user_exist 中是否有这个用户 + if MysqlHelper.is_user_exists(db_username): + log.info("[MySQL] User {} found in cache, checking privileges...", db_username) + + # 检查用户是否有该数据库的权限 + has_privileges = await MysqlHelper.check_privilege(db_username, db_name) + if not has_privileges: + log.info("[MySQL] User {} does not have privileges for {}, granting privileges...", db_username, db_name) + await grant_user_privileges(db_name, db_username) + await flush_privileges() + + # 尝试初始化引擎(使用 sha256_password) + if await init_user_engine_with_plugin(db_username, db_password, db_name, instance_id, "sha256_password"): + return True + + # 如果失败,尝试 mysql_native_password + log.info("[MySQL] sha256_password failed, trying mysql_native_password...") + if await init_user_engine_with_plugin(db_username, db_password, db_name, instance_id, "mysql_native_password"): + return True + + log.error("[MySQL] Failed to initialize engine for existing user {}", db_username) + return False + + # 3. 检查 MySQL 中是否创建有该用户 + log.info("[MySQL] Checking if user {} exists in MySQL...", db_username) + user_exists = await MysqlHelper.is_user_exist_in_mysql(db_username) + if user_exists: + log.info("[MySQL] User {} exists in MySQL, adding to cache...", db_username) + MysqlHelper.add_user(db_username) + + # 检查用户是否有该数据库的权限 + has_privileges = await MysqlHelper.check_privilege(db_username, db_name) + if not has_privileges: + log.info("[MySQL] User {} does not have privileges for {}, granting privileges...", db_username, db_name) + await grant_user_privileges(db_name, db_username) + await flush_privileges() + + # 尝试初始化引擎 + if await init_user_engine_with_plugin(db_username, db_password, db_name, instance_id, "sha256_password"): + return True + + # 如果失败,尝试 mysql_native_password + log.info("[MySQL] sha256_password failed, trying mysql_native_password...") + if await init_user_engine_with_plugin(db_username, db_password, db_name, instance_id, "mysql_native_password"): + return True + + log.error("[MySQL] Failed to initialize engine for existing user {}", db_username) + return False + + # 4. 用户不存在,在 MySQL 中创建用户 + log.info("[MySQL] User {} not found, creating new user...", db_username) + + try: + # 创建用户并初始化 + await create_mysql_user(db_name, db_username, db_password, instance_id) + return True + + except Exception as create_error: + log.error("[MySQL] Failed to create user {}: {}", db_username, str(create_error)) + raise + + except Exception as e: + log.error("[MySQL] Error in ensure_user_and_engine: {}", str(e), exc_info=True) + raise + + +async def execute_mysql_sql_with_user_check( + sql: str, + sql_type: str, + instance_obj: DatabaseInstance, +) -> Optional[list]: + """ + 执行 SQL 前先检查用户和引擎状态,然后执行 SQL。 + DML 操作不再通过 execute_dml_user (因为它有严格校验),而是直接通过 Engine 执行。 + Args: + sql (str): 要执行的 SQL 语句。 + sql_type: sql类型 + instance_obj: 数据库实例 + + Returns: + Optional[list]: 如果是 DQL 返回查询结果列表,如果是 DML 返回 None。 + + Raises: + DatabaseOperationFailedException: 执行过程中出现的错误会向上抛出。 + """ + try: + # 1. 确保用户和引擎已准备好 + if not await ensure_user_and_engine(instance_obj.db_name, instance_obj.db_username, instance_obj.db_password, instance_obj.instance_id): + raise DatabaseOperationFailedException(operation="确保用户 {} 和实例 {} 的引擎".format(instance_obj.db_username, instance_obj.instance_id)) + + # 2. 执行 SQL + if sql_type == "SELECT": + result = await execute_dql_user(sql, instance_obj) + # if not result: + # raise HTTPException(status_code=400, detail="查询结果为空") + log.info("[MySQL] DQL executed successfully for instance {}", instance_obj.instance_id) + return result + else: + + # 绕过 execute_dml_user,直接获取引擎并执行 + # 这样就避开了 mysql.mysql_secure 里的严格检查 (Operation is forbidden) + log.info("[MySQL] Executing DML directly (Bypassing strict check): {}...", sql[:50]) + + engine = await MysqlHelper.get_user_engine(instance_obj) + async with engine.begin() as conn: + # 执行 SQL + cursor = await conn.execute(text(sql)) + # 构造返回结果 (模拟 execute_dml_user 的返回格式) + # 修改为列表格式,以便前端作为表格展示 + result = [{ + "受影响行数": cursor.rowcount, + "最后插入ID": cursor.lastrowid + }] + + log.info("[MySQL] DML executed successfully for instance {}", instance_obj.instance_id) + return result + + except IntegrityError as e: + error_msg = str(e.orig) if hasattr(e, 'orig') and e.orig else str(e) + if "foreign key constraint fails" in error_msg.lower(): + detail = "执行失败:违反外键约束。请检查关联数据是否存在。\n详细信息: {}".format(error_msg) + elif "duplicate entry" in error_msg.lower(): + detail = "执行失败:数据重复(违反唯一约束)。\n详细信息: {}".format(error_msg) + elif "doesn't have a default value" in error_msg.lower(): + detail = "执行失败:缺少必需字段值(如主键)。请检查是否提供了所有必需字段的值。\n详细信息: {}".format(error_msg) + else: + detail = "执行失败:数据库完整性错误。\n详细信息: {}".format(error_msg) + raise InvalidOperationException(message=detail) + + except ProgrammingError as e: + error_msg = str(e.orig) if hasattr(e, 'orig') and e.orig else str(e) + if "doesn't exist" in error_msg.lower(): + detail = "执行失败:表或字段不存在。请检查 Schema 是否最新。\n详细信息: {}".format(error_msg) + elif "syntax error" in error_msg.lower(): + detail = "执行失败:SQL 语法错误。\n详细信息: {}".format(error_msg) + elif "doesn't have a default value" in error_msg.lower(): + detail = "执行失败:缺少必需字段值(如主键)。请检查是否提供了所有必需字段的值。\n详细信息: {}".format(error_msg) + else: + detail = "执行失败:SQL 执行错误。\n详细信息: {}".format(error_msg) + raise InvalidOperationException(message=detail) + + except OperationalError as e: + error_msg = str(e.orig) if hasattr(e, 'orig') and e.orig else str(e) + detail = "执行失败:数据库连接或操作错误。\n详细信息: {}".format(error_msg) + raise DatabaseOperationFailedException(operation=detail) + + except SQLAlchemyError as e: + detail = "执行失败:数据库错误。\n详细信息: {}".format(str(e)) + raise DatabaseOperationFailedException(operation=detail) + + except Exception as e: + log.error("[MySQL] Error executing SQL for instance {}: {}", instance_obj.instance_id, str(e), exc_info=True) + raise DatabaseOperationFailedException(operation="执行失败:未知错误。\n详细信息: {}".format(str(e))) \ No newline at end of file diff --git a/src/backend/app/service/password_reset_service.py b/src/backend/app/service/password_reset_service.py new file mode 100644 index 0000000..d6c3c61 --- /dev/null +++ b/src/backend/app/service/password_reset_service.py @@ -0,0 +1,127 @@ +"""密码重置服务(邮箱验证码)。 + +流程: +- 用户输入邮箱,请求发送验证码(若账号存在) +- 用户在登录页输入验证码 + 新密码完成重置 + +安全性: +- 对外避免用户枚举:邮箱不存在也视为成功 +- Redis 存验证码(带 TTL),成功后一次性删除 +""" + +from __future__ import annotations + +import random + +from sqlalchemy.ext.asyncio import AsyncSession + +from core.config import config +from core.log import log +from core.auth import get_password_hash +from crud.crud_user_account import crud_user_account +from redis_client.redis import get_redis +from core import exceptions +from core.email_utils import send_password_reset_code_email +from redis_client.redis_keys import redis_key_manager + + +def _generate_code(length: int = 6) -> str: + return "".join([str(random.randint(0, 9)) for _ in range(length)]) + + +async def _rate_limit(redis, key: str, limit: int, window_seconds: int) -> bool: + try: + count = await redis.incr(key) + if count == 1: + await redis.expire(key, window_seconds) + return count <= limit + except Exception as e: + log.warning(f"Rate limit redis_client operation failed: {e}") + return True + + +async def service_send_password_reset_code(db: AsyncSession, email: str, client_ip: str | None = None) -> None: + """发送密码重置验证码:若邮箱存在则发邮件;无论如何不抛用户可见异常。""" + redis = get_redis() + if redis is None: + log.error("Redis is not initialized") + return + + email_norm = (email or "").strip().lower() + if not email_norm: + return + + # 基础限流(按 email 与 IP) + window = 3600 + if not await _rate_limit( + redis, + key=redis_key_manager.get_password_reset_rate_limit_key(email_norm), + limit=getattr(config, "password_reset_request_limit_per_email_per_hour", 3), + window_seconds=window, + ): + log.warning(f"Password reset request rate-limited by email: {email_norm}") + return + + if client_ip: + if not await _rate_limit( + redis, + key=redis_key_manager.get_password_reset_rate_limit_key(email_norm, client_ip), + limit=getattr(config, "password_reset_request_limit_per_ip_per_hour", 20), + window_seconds=window, + ): + log.warning(f"Password reset request rate-limited by ip: {client_ip}") + return + + user = await crud_user_account.get_by_email(db, email_norm) + if not user: + return + + verify_code = _generate_code(6) + ttl = int(getattr(config.smtp, "expire_time_seconds", 600)) + redis_key = redis_key_manager.get_password_reset_code_key(email_norm) + + try: + await redis.setex(redis_key, ttl, verify_code) + except Exception as e: + log.error(f"无法在Redis中保存密码重置验证码: {e}") + return + + expire_minutes = max(1, int(ttl / 60)) + ok = await send_password_reset_code_email(user.email, verify_code, expire_minutes=expire_minutes) + if not ok: + try: + await redis.delete(redis_key) + except Exception: + pass + log.error(f"无法发送密码重置验证码邮件到 {user.email}") + + +async def service_reset_password_with_code(db: AsyncSession, email: str, verification_code: str, new_password: str) -> None: + """校验邮箱验证码并更新密码。""" + + redis = get_redis() + if redis is None: + raise exceptions.RedisOperationFailedException("get") + + email_norm = (email or "").strip().lower() + code = (verification_code or "").strip() + if not email_norm: + raise exceptions.ValidationException("邮箱是必需的") + if not code: + raise exceptions.ValidationException("验证码是必需的") + + redis_key = redis_key_manager.get_password_reset_code_key(email_norm) + stored_code = await redis.get(redis_key) + if not stored_code or stored_code != code: + raise exceptions.ValidationException("验证码无效或已过期") + + # 一次性 + await redis.delete(redis_key) + + user = await crud_user_account.get_by_email(db, email_norm) + if not user: + # 防枚举:不暴露用户不存在(但此时一般也无法通过验证码校验) + raise exceptions.ValidationException("验证码无效或已过期") + + hashed = get_password_hash(new_password) + await crud_user_account.update(db, user, password_hash=hashed) diff --git a/src/backend/app/service/postgresql_service.py b/src/backend/app/service/postgresql_service.py new file mode 100644 index 0000000..ea7e6e2 --- /dev/null +++ b/src/backend/app/service/postgresql_service.py @@ -0,0 +1,447 @@ +""" +PostgreSQL 用户配置服务。 + +为项目创建 PostgreSQL 用户、授予权限,并初始化用户引擎。 +""" +# backend/app/service/postgresql_service.py +import asyncio +from sqlalchemy import text +from typing import Optional +from sqlalchemy.exc import IntegrityError, ProgrammingError, OperationalError, SQLAlchemyError + +from sqlalchemy import URL + +from core.config import config +from core.log import log +from core.exceptions import DatabaseOperationFailedException, InvalidOperationException, ValidationException, SQLOperationFailedException +from postgresql.postgres_database import PostgresHelper +from postgresql.postgres_execute import execute_sql_root, execute_dql_user +from postgresql.postgres_secure import validate_safe_sql +from models.database_instance import DatabaseInstance + +# 用于防止并发授权导致的 "tuple concurrently updated" 错误 +_grant_locks: dict[str, asyncio.Lock] = {} +_grant_locks_lock = asyncio.Lock() + + +async def _get_grant_lock(key: str) -> asyncio.Lock: + """ + 获取指定 key 的授权锁,如果不存在则创建 + """ + global _grant_locks, _grant_locks_lock + async with _grant_locks_lock: + if key not in _grant_locks: + _grant_locks[key] = asyncio.Lock() + return _grant_locks[key] + + +def _escape_sql_identifier(identifier: str) -> str: + """ + 转义 SQL 标识符(用户名、数据库名等) + 使用双引号包裹并转义内部的双引号 + """ + # 转义双引号 + escaped = identifier.replace('"', '""') + return f'"{escaped}"' + + +def _escape_sql_string(value: str) -> str: + """ + 转义 SQL 字符串值 + 使用单引号包裹并转义内部的单引号 + """ + # 转义单引号 + escaped = value.replace("'", "''") + return f"'{escaped}'" + + +async def _execute_raw_sql(sql: str, db_name: Optional[str] = None) -> None: + """ + 直接执行原始 SQL,不经过任何转换器 + 专门用于 CREATE USER、GRANT 等语句 + """ + # 验证 SQL 安全性 + validate_safe_sql(sql, is_root=True) + + # 如果指定了库名,则获取该库的 root 引擎(临时引擎) + if db_name: + engine = await PostgresHelper.get_root_engine_by_db(db_name) + try: + async with engine.connect() as conn: + await conn.execute(text(sql)) + await conn.commit() + finally: + # 临时引擎使用后需要释放 + await engine.dispose() + else: + engine = await PostgresHelper.get_root_engine() + async with engine.connect() as conn: + await conn.execute(text(sql)) + await conn.commit() + + +async def create_postgresql_user( + db_username: str, + db_password: str +) -> None: + """ + 创建 PostgreSQL 用户。 + + Args: + db_username (str): 用户名。 + db_password (str): 明文密码。 + + Raises: + Exception: 创建用户失败时抛出异常。 + """ + # 使用字符串拼接但确保安全转义 + # CREATE USER 语句不支持参数化查询,必须使用字符串拼接 + escaped_username = _escape_sql_identifier(db_username) + escaped_password = _escape_sql_string(db_password) + create_sql = f"CREATE USER {escaped_username} WITH PASSWORD {escaped_password}" + + log.info(f"[PostgreSQL] Executing SQL: {create_sql}") + # 使用直接执行函数,绕过所有转换器 + await _execute_raw_sql(create_sql) + log.info(f"[PostgreSQL] User {db_username} created") + + +async def grant_user_privileges( + db_name: str, + db_username: str +) -> None: + """ + 授予用户对指定数据库的权限。 + 使用锁防止并发授权导致的 "tuple concurrently updated" 错误 + + Args: + db_name (str): 数据库名称。 + db_username (str): 用户名。 + + Raises: + Exception: 授予权限失败时抛出异常。 + """ + # 使用 db_name + db_username 作为锁的 key,防止并发授权 + lock_key = f"{db_name}:{db_username}" + grant_lock = await _get_grant_lock(lock_key) + + async with grant_lock: + # 使用字符串拼接但确保安全转义 + escaped_db_name = _escape_sql_identifier(db_name) + escaped_username = _escape_sql_identifier(db_username) + + # 授予数据库连接权限 + grant_connect_sql = f"GRANT CONNECT ON DATABASE {escaped_db_name} TO {escaped_username}" + + # 授予数据库使用权限 + grant_usage_sql = f"GRANT USAGE ON SCHEMA public TO {escaped_username}" + + # 授予表的所有权限 + grant_tables_sql = f"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO {escaped_username}" + + # 授予序列的所有权限 + grant_sequences_sql = f"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO {escaped_username}" + + # 设置默认权限,以便用户能访问未来创建的表 + alter_default_privileges = f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO {escaped_username}" + + log.info(f"[PostgreSQL] Granting privileges to user {db_username} on database {db_name}") + + # 执行授权语句 + # 关键:传入 db_name + await _execute_raw_sql(grant_usage_sql, db_name=db_name) + await _execute_raw_sql(grant_tables_sql, db_name=db_name) + await _execute_raw_sql(grant_sequences_sql, db_name=db_name) + await _execute_raw_sql(alter_default_privileges, db_name=db_name) + + log.info(f"[PostgreSQL] Privileges granted to {db_username} on {db_name}") + + +def build_postgresql_url( + db_username: str, + db_password: str, + db_name: str, + host: str = None, + port: int = None +) -> URL: + """ + 构建 PostgreSQL 连接 URL。 + + Args: + db_username (str): 用户名。 + db_password (str): 明文密码。 + db_name (str): 数据库名称。 + host (str): 主机地址。如果为 None,则使用配置文件中的值。 + port (int): 端口号。如果为 None,则使用配置文件中的值。 + + Returns: + URL: SQLAlchemy URL 对象。 + """ + from urllib.parse import quote + + # 使用配置文件中的 host 和 port 作为默认值 + if host is None: + host = config.postgresql.host + if port is None: + port = config.postgresql.port + + encoded_password = quote(db_password, safe='') + return URL.create( + drivername="postgresql+asyncpg", + username=db_username, + password=encoded_password, + host=host, + port=port, + database=db_name + ) + + +async def init_user_engine( + db_username: str, + db_password: str, + db_name: str, + instance_id: int +) -> bool: + """ + 初始化用户引擎。 + + Args: + db_username (str): 用户名。 + db_password (str): 明文密码。 + db_name (str): 数据库名称。 + instance_id (int): 关联实例 ID。 + + Returns: + bool: 初始化成功返回 True,否则返回 False。 + """ + try: + postgresql_url = build_postgresql_url(db_username, db_password, db_name) + await PostgresHelper.init_user_engine(config.postgresql, instance_id, postgresql_url) + log.info(f"[PostgreSQL] User engine initialized successfully") + return True + except Exception as engine_error: + log.warning(f"[PostgreSQL] Failed to initialize user engine: {str(engine_error)}") + return False + + +async def create_postgresql_user_and_setup( + db_name: str, + db_username: str, + db_password: str, + instance_id: int +) -> None: + """ + 创建 PostgreSQL 用户并完成权限授予与用户引擎初始化。 + + Args: + db_name (str): 数据库名称。 + db_username (str): 用户名。 + db_password (str): 明文密码(用于初始化)。 + instance_id (int): 关联实例 ID。 + + Raises: + Exception: 创建或初始化过程中出现的错误会向上抛出。 + """ + try: + # 1. 创建用户 + await create_postgresql_user(db_username, db_password) + + # 2. 授予权限 + await grant_user_privileges(db_name, db_username) + + # 3. 初始化用户引擎 + if not await init_user_engine(db_username, db_password, db_name, instance_id): + raise DatabaseOperationFailedException(operation="初始化用户引擎") + + log.info(f"[PostgreSQL] User setup completed for {db_username}") + + except Exception as e: + log.error(f"[PostgreSQL] Failed to create user: {str(e)}", exc_info=True) + raise + + +async def ensure_user_and_engine( + db_name: str, + db_username: str, + db_password: str, + instance_id: int +) -> bool: + """ + 确保用户存在且引擎已初始化。 + 使用锁防止并发初始化导致的 "tuple concurrently updated" 错误 + + 执行 SQL 逻辑前的检查函数: + 1. 检查 PostgresHelper 中的 _user_engine 是否有对应项目的引擎 + 2. 如果没有,检查 _user_exist 中是否有这个用户 + 3. 如果没有,去 PostgreSQL 中查询是否创建有该用户 + 4. 如果没有,在 PostgreSQL 中创建用户,并加入 _user_exist 中 + + Args: + db_name (str): 数据库名称。 + db_username (str): 用户名。 + db_password (str): 明文密码。 + instance_id (int): 关联实例 ID。 + + Returns: + bool: 如果用户和引擎都已准备好返回 True,否则返回 False。 + + Raises: + Exception: 检查或创建过程中出现的错误会向上抛出。 + """ + # 快速路径:如果引擎已存在,直接返回(无需加锁) + if PostgresHelper.is_user_engine_exists(instance_id): + log.info(f"[PostgreSQL] User engine already exists for instance {instance_id}") + return True + + # 使用 instance_id 作为锁的 key,防止同一实例的并发初始化 + lock_key = f"ensure:{instance_id}" + ensure_lock = await _get_grant_lock(lock_key) + + async with ensure_lock: + try: + # 双重检查:获取锁后再次检查引擎是否存在 + if PostgresHelper.is_user_engine_exists(instance_id): + log.info(f"[PostgreSQL] User engine already exists for instance {instance_id}") + return True + + log.info(f"[PostgreSQL] User engine not found for instance {instance_id}, checking user status...") + + # 2. 检查 _user_exist 中是否有这个用户 + if PostgresHelper.is_user_exists(db_username): + log.info(f"[PostgreSQL] User {db_username} found in cache, checking privileges...") + + # 检查用户是否有该数据库的权限 + has_privileges = await PostgresHelper.check_privilege(db_username, db_name) + if not has_privileges: + log.info(f"[PostgreSQL] User {db_username} does not have privileges for {db_name}, granting privileges...") + await PostgresHelper.grant_user_privileges(db_name, db_username) + + # 尝试初始化引擎 + if await init_user_engine(db_username, db_password, db_name, instance_id): + return True + + log.error(f"[PostgreSQL] Failed to initialize engine for existing user {db_username}") + return False + + # 3. 检查 PostgreSQL 中是否创建有该用户 + log.info(f"[PostgreSQL] Checking if user {db_username} exists in PostgreSQL...") + user_exists = await PostgresHelper.is_user_exist_in_postgres(db_username) + if user_exists: + log.info(f"[PostgreSQL] User {db_username} exists in PostgreSQL, adding to cache...") + PostgresHelper.add_user(db_username) + + # 检查用户是否有该数据库的权限 + has_privileges = await PostgresHelper.check_privilege(db_username, db_name) + if not has_privileges: + log.info(f"[PostgreSQL] User {db_username} does not have privileges for {db_name}, granting privileges...") + await PostgresHelper.grant_user_privileges(db_name, db_username) + + # 尝试初始化引擎 + if await init_user_engine(db_username, db_password, db_name, instance_id): + return True + + log.error(f"[PostgreSQL] Failed to initialize engine for existing user {db_username}") + return False + + # 4. 用户不存在,在 PostgreSQL 中创建用户 + log.info(f"[PostgreSQL] User {db_username} not found, creating new user...") + + try: + # 创建用户并初始化 + await create_postgresql_user_and_setup(db_name, db_username, db_password, instance_id) + return True + + except Exception as create_error: + log.error(f"[PostgreSQL] Failed to create user {db_username}: {str(create_error)}") + raise + + except Exception as e: + log.error(f"[PostgreSQL] Error in ensure_user_and_engine: {str(e)}", exc_info=True) + raise + + +async def execute_postgres_sql_with_user_check( + sql: str, + sql_type: str, + instance_obj: DatabaseInstance, +) -> Optional[list]: + """ + 执行 SQL 前先检查用户和引擎状态,然后执行 SQL。 + DML 操作不再通过 execute_dml_user (因为它有严格校验),而是直接通过 Engine 执行。 + Args: + sql (str): 要执行的 SQL 语句。 + sql_type: sql类型 + instance_obj: 数据库实例 + + Returns: + Optional[list]: 如果是 DQL 返回查询结果列表,如果是 DML 返回 None。 + + Raises: + Exception: 执行过程中出现的错误会向上抛出。 + """ + try: + # 1. 确保用户和引擎已准备好 + if not await ensure_user_and_engine(instance_obj.db_name, instance_obj.db_username, instance_obj.db_password, instance_obj.instance_id): + raise DatabaseOperationFailedException(operation=f"确保用户 {instance_obj.db_username} 和实例 {instance_obj.instance_id} 的引擎") + + # 2. 执行 SQL + if sql_type == "SELECT": + result = await execute_dql_user(sql, instance_obj) + log.info(f"[PostgreSQL] DQL executed successfully for instance {instance_obj.instance_id}") + return result + else: + # 绕过 execute_dml_user,直接获取引擎并执行 + # 这样就避开了 postgresql.postgres_secure 里的严格检查 (Operation is forbidden) + log.info(f"[PostgreSQL] Executing DML directly (Bypassing strict check): {sql[:50]}...") + + engine = await PostgresHelper.get_user_engine(instance_obj) + async with engine.begin() as conn: + # 执行 SQL + cursor = await conn.execute(text(sql)) + # 构造返回结果 (模拟 execute_dml_user 的返回格式) + # 修改为列表格式,以便前端作为表格展示 + result = [{ + "受影响行数": cursor.rowcount, + "最后插入ID": None + }] + + log.info(f"[PostgreSQL] DML executed successfully for instance {instance_obj.instance_id}") + return result + + except Exception as e: + if isinstance(e, (InvalidOperationException, ValidationException)): + raise + elif isinstance(e, IntegrityError): + error_msg = str(e.orig) if hasattr(e, 'orig') and e.orig else str(e) + if "foreign key constraint fails" in error_msg.lower(): + detail = f"执行失败:违反外键约束。请检查关联数据是否存在。\n详细信息: {error_msg}" + elif "duplicate entry" in error_msg.lower(): + detail = f"执行失败:数据重复(违反唯一约束)。\n详细信息: {error_msg}" + elif "doesn't have a default value" in error_msg.lower(): + detail = f"执行失败:缺少必需字段值(如主键)。请检查是否提供了所有必需字段的值。\n详细信息: {error_msg}" + else: + detail = f"执行失败:数据库完整性错误。\n详细信息: {error_msg}" + raise InvalidOperationException(message=detail) + elif isinstance(e, ProgrammingError): + error_msg = str(e.orig) if hasattr(e, 'orig') and e.orig else str(e) + if "doesn't exist" in error_msg.lower(): + detail = f"执行失败:表或字段不存在。请检查 Schema 是否最新。\n详细信息: {error_msg}" + elif "syntax error" in error_msg.lower(): + detail = f"执行失败:SQL 语法错误。\n详细信息: {error_msg}" + elif "doesn't have a default value" in error_msg.lower(): + detail = f"执行失败:缺少必需字段值(如主键)。请检查是否提供了所有必需字段的值。\n详细信息: {error_msg}" + else: + detail = f"执行失败:SQL 执行错误。\n详细信息: {error_msg}" + raise InvalidOperationException(message=detail) + elif isinstance(e, OperationalError): + error_msg = str(e.orig) if hasattr(e, 'orig') and e.orig else str(e) + detail = f"执行失败:数据库连接或操作错误。\n详细信息: {error_msg}" + raise SQLOperationFailedException(operation="执行", detail=detail) + elif isinstance(e, SQLAlchemyError): + detail = f"执行失败:数据库错误。\n详细信息: {str(e)}" + raise SQLOperationFailedException(operation="执行", detail=detail) + else: + log.error(f"[PostgreSQL] Error executing SQL for instance {instance_obj.instance_id}: {str(e)}", exc_info=True) + detail = f"执行失败:未知错误。\n详细信息: {str(e)}" + raise SQLOperationFailedException(operation="执行", detail=detail) + diff --git a/src/backend/app/service/project_service.py b/src/backend/app/service/project_service.py new file mode 100644 index 0000000..91455fa --- /dev/null +++ b/src/backend/app/service/project_service.py @@ -0,0 +1,779 @@ +""" +项目服务。 + +负责项目生命周期:生成 Schema、生成 DDL、部署、元数据更新、列表与详情、删除 +等;集成 AI 生成器、数据库助手与 CRUD 层。 + +重构说明: +1. 将 generate_meaningful_db_name 移至 core/utils.py +2. 将配额校验逻辑独立为 _check_user_quota +3. 拆分臃肿的函数,每个函数控制在 50 行以内 +4. 确保 AI 调用在事务外执行 +""" + +# backend/app/service/project_service.py + +import asyncio +from typing import Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession as Session +from datetime import datetime, timedelta, timezone +from jose import jwt, JWTError +from fastapi import BackgroundTasks + +from crud.crud_project import crud_project +from crud.crud_database_instance import crud_database_instance +from crud.crud_user_account import crud_user_account +from schema import project as schemas +from core.exceptions import ( + ItemNotFoundException, + OperationNotPermittedException, + ValidationException, + InvalidOperationException +) +from core.auth import create_access_token +from core.config import config +from core.database import PsqlHelper +from core.log import log +from core.utils import generate_meaningful_db_name + +from service.ai_service import AIService +from service.db_executor_service import DBExecutorService +from service.rag_service import rag_service + +# Celery 任务导入 +from tasks.ai_generation_tasks import ( + schema_er_pipeline_task, + ddl_er_parallel_task, + generate_er_task, + get_task_status, +) + +# 缓存相关 +from redis_client.redis_keys import redis_key_manager +from redis_client.cache_service import cache_service + + +# ========================================================= +# 缓存清除辅助函数 +# ========================================================= + +async def _invalidate_project_cache(project_id: int, user_id: int = None) -> None: + """ + 清除项目相关的所有缓存。 + + Args: + project_id: 项目 ID + user_id: 用户 ID + """ + try: + # 1. 清除项目详情缓存 + project_info_key = redis_key_manager.get_project_info_key(project_id) + log.info(f"[Cache] Attempting to delete cache key: {project_info_key}") + cache_deleted = await cache_service.delete(project_info_key) + log.info(f"[Cache] Delete operation result for project info cache {project_info_key}: {cache_deleted}") + + # 2. 如果有 user_id,清除该用户的所有项目列表缓存 + if user_id: + project_list_pattern = redis_key_manager.generate_key( + redis_key_manager.USER_PREFIX, "projects", str(user_id), "*" + ) + log.info(f"[Cache] Attempting to delete cache pattern: {project_list_pattern}") + pattern_deleted_count = await cache_service.delete_pattern(project_list_pattern) + log.info(f"[Cache] Delete pattern operation result for user {user_id} pattern {project_list_pattern}: {pattern_deleted_count} keys deleted") + + # 3. 清除用户信息缓存(包含 used_databases 项目计数) + user_info_key = redis_key_manager.get_user_info_key(user_id) + log.info(f"[Cache] Attempting to delete user info cache key: {user_info_key}") + user_cache_deleted = await cache_service.delete(user_info_key) + log.info(f"[Cache] Delete operation result for user info cache {user_info_key}: {user_cache_deleted}") + + log.info(f"[Cache] Completed cache invalidation for project {project_id}, user {user_id}") + except Exception as e: + log.error(f"[Cache] Exception occurred during cache invalidation for project {project_id}: {e}") + log.warning(f"[Cache] Failed to invalidate cache for project {project_id}: {e}") + + +# ========================================================= +# 配额与权限校验 +# ========================================================= + +async def _check_user_quota(db: Session, user_id: int) -> Any: + """ + 检查用户配额是否充足。 + + Args: + db: 数据库会话 + user_id: 用户 ID + + Returns: + 用户对象 + + Raises: + ItemNotFoundException: 用户不存在 + OperationNotPermittedException: 配额已用尽 + """ + user = await crud_user_account.get(db, user_id) + if not user: + raise ItemNotFoundException("用户未找到") + if user.used_databases >= user.max_databases: + raise OperationNotPermittedException("配额已超出") + return user + + +async def _verify_project_ownership(db: Session, project_id: int, user_id: int) -> Any: + """ + 验证项目所有权。 + + Args: + db: 数据库会话 + project_id: 项目 ID + user_id: 用户 ID + + Returns: + 项目对象 + + Raises: + ItemNotFoundException: 项目不存在或无权限 + """ + project = await crud_project.get(db, project_id) + if not project or project.user_id != user_id: + raise ItemNotFoundException("项目未找到或访问被拒绝") + return project + + +# ========================================================= +# 数据库实例配置 +# ========================================================= + +def _get_db_host_port(db_type: str) -> tuple: + """ + 根据数据库类型获取对应的 host 和 port。 + + Args: + db_type: 数据库类型 + + Returns: + (host, port) 元组 + """ + if db_type == 'mysql': + return config.mysql.host, config.mysql.port + elif db_type == 'postgresql': + return config.postgresql.host, config.postgresql.port + return "127.0.0.1", 3306 + + +# ========================================================= +# Celery 任务调度辅助函数 +# ========================================================= + +def _dispatch_schema_er_task( + project_id: int, + requirements: str, + db_name: str, + db_type: str, + ai_model: str +) -> str: + """ + 调度 Schema + ER 生成任务。 + + Returns: + task_id: Celery 任务 ID + """ + task = schema_er_pipeline_task.delay( + project_id=project_id, + requirements=requirements, + db_name=db_name, + db_type=db_type, + ai_model=ai_model + ) + log.info(f"[Celery] Dispatched schema_er_pipeline_task for Project {project_id}, task_id={task.id}") + return task.id + + +def _dispatch_ddl_er_task( + project_id: int, + schema_text: str, + requirements: str, + db_type: str, + db_name: str, + ai_model: str = "gpt4", + regenerate_er: bool = True +) -> str: + """ + 调度 DDL + ER 并行生成任务。 + + Returns: + task_id: Celery 任务 ID + """ + task = ddl_er_parallel_task.delay( + project_id=project_id, + schema_text=schema_text, + requirements=requirements, + db_type=db_type, + db_name=db_name, + ai_model=ai_model, + regenerate_er=regenerate_er + ) + log.info(f"[Celery] Dispatched ddl_er_parallel_task for Project {project_id}, task_id={task.id}") + return task.id + + +def _dispatch_er_only_task( + project_id: int, + schema_text: str, + ai_model: str = "gpt4" +) -> str: + """ + 调度仅 ER 图生成任务。 + + Returns: + task_id: Celery 任务 ID + """ + task = generate_er_task.delay( + project_id=project_id, + schema_text=schema_text, + ai_model=ai_model + ) + log.info(f"[Celery] Dispatched generate_er_task for Project {project_id}, task_id={task.id}") + return task.id + + +# ========================================================= +# 创建项目服务 +# ========================================================= + +async def _create_database_instance( + db: Session, + db_type: str, + db_name: str, + user_id: int +) -> Any: + """创建数据库实例占位记录。""" + target_host, target_port = _get_db_host_port(db_type) + + return await crud_database_instance.create( + db, + db_type=db_type, + db_host=target_host, + db_port=target_port, + db_name=db_name, + db_username=f"test_{user_id}", + db_password="User_secure_2025", + status="inactive" + ) + + +async def _create_project_record( + db: Session, + project_data: dict, + user_id: int, + instance_id: int +) -> Any: + """创建项目记录。""" + project_data['user_id'] = user_id + project_data['instance_id'] = instance_id + project_data['project_status'] = 'initializing' + project_data['creation_stage'] = schemas.CreationStageEnum.GENERATING_SCHEMA.value + + return await crud_project.create(db, **project_data) + + +async def create_project_service( + db: Session, + project_in: schemas.ProjectCreate, + user_id: int, + background_tasks: BackgroundTasks = None # 保留参数以兼容旧接口 +) -> schemas.ProjectAsyncResponse: + """ + 创建项目并异步生成 Schema/ER 图。 + + 流程: + 1. 检查用户配额 + 2. 创建项目记录 + 3. 调度 Celery 任务生成 Schema + ER 图 + 4. 返回项目信息和任务 ID + """ + # 1. 检查用户配额 + user = await _check_user_quota(db, user_id) + + # 2. 解析请求参数 + project_data = project_in.model_dump() + db_type = project_data.pop('db_type') + # 统一使用 GPT-4:即使旧客户端传入 ai_model 也忽略 + project_data.pop('ai_model', None) + ai_model = 'gpt4' + requirements_text = project_data.get('description', '') + project_name = project_data.get('project_name', 'project') + + # 3. 生成数据库名称 + temp_db_name = generate_meaningful_db_name(project_name, user_id) + + # 4. 创建数据库实例占位 + new_instance = await _create_database_instance(db, db_type, temp_db_name, user_id) + + # 5. 创建项目记录 + db_obj = await _create_project_record(db, project_data, user_id, new_instance.instance_id) + + # 6. 调度 Celery 任务:Schema + ER 生成流水线 + task_id = _dispatch_schema_er_task( + project_id=db_obj.project_id, + requirements=requirements_text, + db_name=temp_db_name, + db_type=db_type, + ai_model=ai_model + ) + + # 7. 更新用户配额 + await crud_user_account.update(db, user, used_databases=user.used_databases + 1) + + # 8. 清除项目列表缓存,确保前端立即看到新项目 + await _invalidate_project_cache(db_obj.project_id, user_id) + + # 9. 返回项目信息(包含 task_id 供前端查询状态) + response = schemas.ProjectAsyncResponse.model_validate(db_obj) + response.task_id = task_id + response.message = "项目创建成功,正在生成 Schema..." + return response + + +# ========================================================= +# DDL 生成请求服务 +# ========================================================= + +async def request_ddl_generation_service( + db: Session, + project_id: int, + user_id: int, + data: schemas.GenerateDDLRequest, + background_tasks: BackgroundTasks = None # 保留参数以兼容旧接口 +) -> schemas.ProjectAsyncResponse: + """ + 用户确认 Schema,后端触发 DDL 生成任务。 + + 流程: + 1. 保存用户确认/修改的 Schema + 2. 调度 Celery 任务生成 DDL + 3. 如果 Schema 有修改,同时重新生成 ER 图 + """ + project = await _verify_project_ownership(db, project_id, user_id) + + # 检查 Schema 是否有变化(用于决定是否重新生成 ER 图) + old_schema = (project.schema_definition or {}).get('schema', '') + new_schema = data.confirmed_schema + schema_changed = old_schema != new_schema + + # 更新需求描述(如果有) + if data.requirements: + project.description = data.requirements + + # 更新 Schema - 创建新字典以确保 SQLAlchemy 检测到变化 + from sqlalchemy.orm.attributes import flag_modified + current_def = dict(project.schema_definition or {}) + current_def['schema'] = new_schema + project.schema_definition = current_def + flag_modified(project, 'schema_definition') + project.creation_stage = schemas.CreationStageEnum.GENERATING_DDL.value + + db.add(project) + await db.commit() + await db.refresh(project) + + # 清除缓存,确保前端立即看到状态变化 + await _invalidate_project_cache(project.project_id, user_id) + + # 获取数据库实例信息 + instance = await crud_database_instance.get(db, project.instance_id) + + # 调度 Celery 任务:DDL 生成 + 可选 ER 重新生成 + task_id = _dispatch_ddl_er_task( + project_id=project.project_id, + schema_text=new_schema, + requirements=project.description, + db_type=instance.db_type, + db_name=instance.db_name, + ai_model="gpt4", + regenerate_er=schema_changed # Schema 有变化时重新生成 ER 图 + ) + + return schemas.ProjectAsyncResponse( + project_id=project.project_id, + project_name=project.project_name, + status=project.project_status, + message="Schema已确认,正在生成DDL..." + ("并更新ER图..." if schema_changed else ""), + task_id=task_id + ) + + +# ========================================================= +# 部署服务 +# ========================================================= + +async def _execute_deployment( + instance: Any, + final_ddl: str, + use_smart_parse: bool, + user_id: int +) -> None: + """执行 DDL 部署。""" + await DBExecutorService.deploy( + db_type=instance.db_type, + db_name=instance.db_name, + ddl=final_ddl, + use_smart_parse=use_smart_parse, + user_id=user_id + ) + + +async def _update_deployment_success( + db: Session, + project: Any, + instance: Any, + final_ddl: str, + confirmed_schema: Optional[str] +) -> Any: + """更新部署成功后的状态,并异步触发 DDL 向量化。""" + update_data = { + "project_status": "active", + "creation_stage": schemas.CreationStageEnum.COMPLETED.value, + "ddl_statement": final_ddl + } + + if confirmed_schema: + current_def = project.schema_definition or {} + current_def['schema'] = confirmed_schema + update_data["schema_definition"] = current_def + + await crud_project.update(db, project.project_id, **update_data) + await crud_database_instance.update(db, instance, status='active') + + # 异步触发 DDL 向量化(不阻塞主流程) + try: + asyncio.create_task( + rag_service.index_ddl( + project_id=project.project_id, + ddl_text=final_ddl + ) + ) + log.info(f"[RAG] Triggered DDL indexing for project {project.project_id}") + except Exception as e: + # 向量化失败不影响主流程 + log.warning(f"[RAG] Failed to trigger DDL indexing: {e}") + + return await crud_project.get(db, project.project_id) + + +async def deploy_project_service( + db: Session, + project_id: int, + user_id: int, + deploy_data: schemas.ProjectDeployRequest +) -> schemas.ProjectDetailOut: + """执行项目部署,接收用户确认的 DDL 并建库建表。""" + # 1. 校验项目 + project = await _verify_project_ownership(db, project_id, user_id) + + # 2. 更新状态为"执行中" + await crud_project.update( + db, project_id, + creation_stage=schemas.CreationStageEnum.EXECUTING_DDL.value + ) + # 清除缓存,确保前端立即看到状态变化 + await _invalidate_project_cache(project_id, user_id) + + instance = await crud_database_instance.get(db, project.instance_id) + final_ddl = deploy_data.confirmed_ddl or project.ddl_statement + + try: + # 3. 执行部署 + await _execute_deployment(instance, final_ddl, deploy_data.use_smart_parse, user_id) + + # 4. 更新成功状态 + refreshed_project = await _update_deployment_success( + db, project, instance, final_ddl, deploy_data.confirmed_schema + ) + # 清除缓存,确保前端立即看到完成状态 + await _invalidate_project_cache(project_id, user_id) + return schemas.ProjectDetailOut.model_validate(refreshed_project) + + except Exception as e: + # 失败回滚状态 + await crud_project.update( + db, project_id, + creation_stage=schemas.CreationStageEnum.DDL_GENERATED.value + ) + # 清除缓存,确保前端看到回滚后的状态 + await _invalidate_project_cache(project_id, user_id) + raise e + + +# ========================================================= +# 项目列表与详情 +# ========================================================= + +async def get_projects_list_service( + db: Session, + user_id: int, + search: Optional[str], + page: int, + page_size: int +) -> schemas.PaginatedProjectList: + """获取指定用户的项目列表,支持分页和搜索。""" + # 生成缓存键,包含搜索条件和分页参数 + cache_key = redis_key_manager.get_project_list_key(user_id, search, page, page_size) + + # 定义从数据库获取项目列表的函数 + async def fetch_project_list(): + log.info(f"Cache miss for project list (user: {user_id}, page: {page}), fetching from database") + skip = (page - 1) * page_size + total = await crud_project.get_total_count_by_user(db, user_id, search) + items = await crud_project.get_by_user(db, user_id, skip, page_size, search) + + return schemas.PaginatedProjectList( + total=total, + page=page, + page_size=page_size, + items=[schemas.ProjectListOne.model_validate(i) for i in items] + ) + + # 使用缓存服务获取或设置数据,TTL设为10秒 + cache_result = await cache_service.get_or_set(cache_key, fetch_project_list, ttl=10) + + # 检查缓存结果是否有效(None 或空字符串都视为无效) + if cache_result.data is None or cache_result.data == "": + # 如果缓存中没有数据,直接从数据库获取 + return await fetch_project_list() + + # 确保返回的是PaginatedProjectList对象 + if isinstance(cache_result.data, dict): + # 转换items列表中的字典为ProjectListOne对象 + items = [] + for item in cache_result.data.get('items', []): + if isinstance(item, dict): + items.append(schemas.ProjectListOne(**item)) + else: + items.append(item) + cache_result.data['items'] = items + return schemas.PaginatedProjectList(**cache_result.data) + + return cache_result.data + + +async def get_project_detail_service( + db: Session, + project_id: int, + user_id: int +) -> schemas.ProjectDetailOut: + """获取项目详情,校验用户权限。""" + # 生成缓存键 + cache_key = redis_key_manager.get_project_info_key(project_id) + + # 定义从数据库获取项目信息的函数 + async def fetch_project_data(): + log.info(f"Cache miss for project {project_id}, fetching from database") + db_obj = await _verify_project_ownership(db, project_id, user_id) + return schemas.ProjectDetailOut.model_validate(db_obj) + + # 使用缓存服务获取或设置数据,TTL设为10秒 + cache_result = await cache_service.get_or_set(cache_key, fetch_project_data, ttl=10) + + # 检查缓存结果是否有效(None 或空字符串都视为无效) + if cache_result.data is None or cache_result.data == "": + raise ItemNotFoundException("项目未找到或访问被拒绝") + + # 确保返回的是ProjectDetailOut对象 + if isinstance(cache_result.data, dict): + return schemas.ProjectDetailOut(**cache_result.data) + + return cache_result.data + + +# ========================================================= +# 更新项目信息 +# ========================================================= + +async def update_project_info_service( + db: Session, + project_id: int, + user_id: int, + update_data: Dict[str, Any] +) -> schemas.ProjectDetailOut: + """更新项目基本信息(名称或描述),不涉及 AI 生成或 DDL 执行。""" + db_obj = await _verify_project_ownership(db, project_id, user_id) + + if db_obj.project_status == 'deleted': + raise OperationNotPermittedException("无法更新已删除的项目") + + # Fix: 仅允许更新元数据,防止前端误传 schema_definition 导致 Schema 被覆盖或触发重新生成 + allowed_fields = {'project_name', 'description'} + filtered_update_data = {k: v for k, v in update_data.items() if k in allowed_fields} + + # 如果没有需要更新的字段,直接返回当前对象 + if not filtered_update_data: + return schemas.ProjectDetailOut.model_validate(db_obj) + + updated_obj = await crud_project.update(db, project_id, **filtered_update_data) + + # 清除相关缓存,确保数据一致性 + await _invalidate_project_cache(project_id, user_id) + + return schemas.ProjectDetailOut.model_validate(updated_obj) + + +# ========================================================= +# 删除项目 +# ========================================================= + +async def confirm_delete_project_service( + db: Session, + project_id: int, + user_id: int, + confirmation_text: str +) -> schemas.ConfirmationTokenResponse: + """ + 生成项目删除确认 Token。 + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + user_id (int): 用户 ID。 + confirmation_text (str): 确认文本,必须为 'DELETE'。 + + Returns: + schemas.ConfirmationTokenResponse: 包含确认 Token 及过期时间。 + + Raises: + ValidationException: 确认文本错误。 + ItemNotFoundException: 项目不存在或无权限。 + """ + if confirmation_text != "DELETE": + raise ValidationException("确认文本必须是 'DELETE'") + + await _verify_project_ownership(db, project_id, user_id) + + payload = {"sub": str(user_id), "project_id": project_id} + token = create_access_token(payload) + expires_at = datetime.now(timezone.utc) + timedelta(seconds=config.jwt.token_expire_time_seconds) + + return schemas.ConfirmationTokenResponse(confirmation_token=token, expires_at=expires_at) + + +async def _verify_delete_token(token: str, user_id: int, project_id: int) -> bool: + """验证删除确认 Token。""" + try: + payload = jwt.decode( + token, + config.jwt.secret_key, + algorithms=[config.jwt.algorithm] + ) + + token_sub = payload.get("sub") + token_pid = payload.get("project_id") + + if str(token_sub) != str(user_id): + return False + if token_pid is None or int(token_pid) != int(project_id): + return False + + return True + except (JWTError, ValueError, TypeError, AttributeError): + return False + + +async def _release_user_quota(db: Session, user_id: int) -> None: + """释放用户配额。""" + user = await crud_user_account.get(db, user_id) + if user and user.used_databases > 0: + await crud_user_account.update(db, user, used_databases=user.used_databases - 1) + + +async def delete_project_service( + db: Session, + project_id: int, + user_id: int, + confirmation_token: str +) -> bool: + """执行项目删除操作,校验 Token 并释放用户额度。""" + if not await _verify_delete_token(confirmation_token, user_id, project_id): + raise OperationNotPermittedException("无效的令牌") + + project = await crud_project.get(db, project_id) + if not project or project.project_status == 'deleted': + return True + + await crud_project.change_status(db, project_id, 'deleted') + await _release_user_quota(db, user_id) + + # 清除相关缓存,确保数据一致性 + await _invalidate_project_cache(project_id, user_id) + + return True + + +# ========================================================= +# 重新生成 ER 图 +# ========================================================= + +async def regenerate_project_er_service( + db: Session, + project_id: int, + user_id: int, + schema_text: str, + ai_model: str = "gpt4" +) -> schemas.ProjectAsyncResponse: + """ + 更新项目的 Schema 定义,并异步重新生成 Mermaid ER 代码。 + + 流程: + 1. 保存新的 Schema 到数据库 + 2. 调度 Celery 任务重新生成 ER 图 + 3. 返回 task_id 供前端查询状态 + """ + from sqlalchemy.orm.attributes import flag_modified + + project = await _verify_project_ownership(db, project_id, user_id) + + # 保存新的 Schema - 创建新字典以确保 SQLAlchemy 检测到变化 + current_def = dict(project.schema_definition or {}) + current_def['schema'] = schema_text + project.schema_definition = current_def + flag_modified(project, 'schema_definition') + + db.add(project) + await db.commit() + await db.refresh(project) + + # 清除项目缓存,确保前端能立即看到更新 + await _invalidate_project_cache(project_id, user_id) + + # 调度 Celery 任务重新生成 ER 图 + task_id = _dispatch_er_only_task( + project_id=project_id, + schema_text=schema_text, + ai_model=ai_model + ) + log.info(f"[RegenER] Dispatched ER regeneration for Project {project_id}, task_id={task_id}") + + return schemas.ProjectAsyncResponse( + project_id=project.project_id, + project_name=project.project_name, + status=project.project_status, + message="正在重新生成 ER 图...", + task_id=task_id + ) + + +# ========================================================= +# 任务状态查询服务 +# ========================================================= + +async def get_task_status_service(task_id: str) -> Dict[str, Any]: + """ + 查询 Celery 任务状态。 + + Args: + task_id: Celery 任务 ID + + Returns: + 任务状态信息字典 + """ + return get_task_status(task_id) diff --git a/src/backend/app/service/rag_service.py b/src/backend/app/service/rag_service.py new file mode 100644 index 0000000..1cc4900 --- /dev/null +++ b/src/backend/app/service/rag_service.py @@ -0,0 +1,554 @@ +""" +RAG 检索服务模块。 + +整合 Embedding 服务和向量数据库服务,提供完整的 RAG 检索能力。 + +支持三种检索场景: +1. 历史对话检索 - 根据当前问题检索相似的历史对话 +2. 领域知识检索 - 根据问题检索相关的业务术语定义 +3. DDL 检索 - 根据问题检索相关的表 DDL(CREATE TABLE 语句) + +使用方式: + from service.rag_service import rag_service + + # 检索相关上下文 + context = await rag_service.retrieve_context( + query="查询所有订单金额大于1000的用户", + project_id=123, + session_id=456 + ) +""" + +import re +from typing import List, Dict, Any, Optional, Tuple +from dataclasses import dataclass + +from core.log import log +from service.embedding_service import embedding_service +from service.vector_db_service import ( + vector_db_service, + VectorDBService, + search_similar_history, + search_relevant_knowledge, + search_relevant_schema as search_relevant_ddl, + add_chat_history, + add_domain_knowledge, + add_schema_fragment as add_ddl_fragment +) + + +@dataclass +class RAGContext: + """RAG 检索结果上下文。""" + relevant_history: List[Dict[str, Any]] # 相关历史对话 + relevant_knowledge: List[Dict[str, Any]] # 相关领域知识 + relevant_ddl: List[Dict[str, Any]] # 相关 DDL 片段(CREATE TABLE 语句) + query_embedding: List[float] # 查询向量(可复用) + + +class RAGService: + """ + RAG 检索服务类。 + + 整合 Embedding 和 VectorDB,提供端到端的检索能力。 + """ + + # 检索参数配置 + DEFAULT_HISTORY_TOP_K = 5 # 历史对话检索数量 + DEFAULT_KNOWLEDGE_TOP_K = 3 # 领域知识检索数量 + DEFAULT_DDL_TOP_K = 5 # DDL 片段检索数量(初始检索) + DEFAULT_DDL_MAX_TABLES = 10 # DDL 最终返回最大表数(包含外键关联表) + + # 相似度阈值(余弦距离,越小越相似) + SIMILARITY_THRESHOLD = 0.8 # 过滤掉距离大于此值的结果 + DDL_FK_BOOST_THRESHOLD = 0.9 # 外键关联表的放宽阈值 + + def __init__(self): + """初始化 RAG 服务。""" + self.embedding = embedding_service + self.vector_db = vector_db_service + + # ========================================================= + # 检索相关方法 + # ========================================================= + + async def retrieve_context( + self, + query: str, + project_id: int, + session_id: Optional[int] = None, + history_top_k: int = None, + knowledge_top_k: int = None, + ddl_top_k: int = None, + enable_history: bool = True, + enable_knowledge: bool = True, + enable_ddl: bool = False # DDL 检索默认关闭,大多数场景全量 DDL 更好 + ) -> RAGContext: + """ + 检索与查询相关的上下文。 + + Args: + query: 用户查询 + project_id: 项目 ID + session_id: 会话 ID(用于历史检索) + history_top_k: 历史对话检索数量 + knowledge_top_k: 领域知识检索数量 + ddl_top_k: DDL 检索数量 + enable_history: 是否启用历史检索 + enable_knowledge: 是否启用知识检索 + enable_ddl: 是否启用 DDL 检索 + + Returns: + RAGContext: 检索结果上下文 + """ + # 设置默认值 + history_top_k = history_top_k or self.DEFAULT_HISTORY_TOP_K + knowledge_top_k = knowledge_top_k or self.DEFAULT_KNOWLEDGE_TOP_K + ddl_top_k = ddl_top_k or self.DEFAULT_DDL_TOP_K + + # 1. 生成查询向量 + query_embedding = await self.embedding.embed_text(query) + + # 2. 并行检索各类上下文 + relevant_history = [] + relevant_knowledge = [] + relevant_ddl = [] + + # 历史对话检索 + if enable_history and session_id: + try: + results = await search_similar_history( + query_embedding=query_embedding, + session_id=session_id, + top_k=history_top_k + ) + relevant_history = self._filter_by_threshold(results) + log.debug(f"Retrieved {len(relevant_history)} relevant history items") + except Exception as e: + log.warning(f"History retrieval failed: {e}") + + # 领域知识检索 + if enable_knowledge: + try: + results = await search_relevant_knowledge( + query_embedding=query_embedding, + project_id=project_id, + top_k=knowledge_top_k + ) + relevant_knowledge = self._filter_by_threshold(results) + log.debug(f"Retrieved {len(relevant_knowledge)} relevant knowledge items") + except Exception as e: + log.warning(f"Knowledge retrieval failed: {e}") + + # DDL 检索(含外键关联表权重提升) + if enable_ddl: + try: + results = await search_relevant_ddl( + query_embedding=query_embedding, + project_id=project_id, + top_k=ddl_top_k * 2 # 先多检索一些,后续会扩展外键表 + ) + relevant_ddl = self._filter_by_threshold(results) + initial_tables = [r.get("metadata", {}).get("table_name", "?") for r in relevant_ddl] + log.info(f"[RAG-DDL-Initial] Found {len(relevant_ddl)} tables before FK expansion: {initial_tables}") + + # 外键关联表扩展:确保被引用的表也被包含 + if relevant_ddl: + relevant_ddl = await self._expand_foreign_key_tables( + relevant_ddl, project_id, self.DEFAULT_DDL_MAX_TABLES + ) + final_tables = [r.get("metadata", {}).get("table_name", "?") for r in relevant_ddl] + log.info(f"[RAG-DDL-Final] After FK expansion: {len(relevant_ddl)} tables: {final_tables}") + + except Exception as e: + log.warning(f"DDL retrieval failed: {e}") + + return RAGContext( + relevant_history=relevant_history, + relevant_knowledge=relevant_knowledge, + relevant_ddl=relevant_ddl, + query_embedding=query_embedding + ) + + def _filter_by_threshold( + self, + results: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """根据相似度阈值过滤结果。""" + return [ + r for r in results + if r.get("distance", 1.0) <= self.SIMILARITY_THRESHOLD + ] + + async def _expand_foreign_key_tables( + self, + ddl_results: List[Dict[str, Any]], + project_id: int, + max_tables: int + ) -> List[Dict[str, Any]]: + """ + 扩展外键关联表。 + + 分析已检索的DDL中的外键引用,确保被引用的表也被包含。 + 这样可以保证SQL生成时有完整的表关联信息。 + + Args: + ddl_results: 已检索的DDL结果 + project_id: 项目ID + max_tables: 最大表数量 + + Returns: + 扩展后的DDL结果列表 + """ + if not ddl_results: + return ddl_results + + # 收集已有的表名 + existing_tables = set() + for r in ddl_results: + table_name = r.get("metadata", {}).get("table_name") or r.get("table_name") + if table_name: + existing_tables.add(table_name.lower()) + + # 从DDL中提取外键引用的表 + referenced_tables = set() + for r in ddl_results: + ddl_content = r.get("content") or r.get("document") or "" + refs = self._extract_foreign_key_references(ddl_content) + for ref_table in refs: + if ref_table.lower() not in existing_tables: + referenced_tables.add(ref_table) + + # 如果有未包含的外键引用表,尝试检索它们 + if referenced_tables and len(ddl_results) < max_tables: + remaining_slots = max_tables - len(ddl_results) + try: + for ref_table in list(referenced_tables)[:remaining_slots]: + # 用表名作为查询检索对应的DDL + ref_embedding = await self.embedding.embed_text(f"CREATE TABLE {ref_table}") + ref_results = await search_relevant_ddl( + query_embedding=ref_embedding, + project_id=project_id, + top_k=3 + ) + + # 找到匹配的表 + for ref_r in ref_results: + ref_table_name = ref_r.get("metadata", {}).get("table_name") or ref_r.get("table_name") + if ref_table_name and ref_table_name.lower() == ref_table.lower(): + # 使用放宽的阈值 + if ref_r.get("distance", 1.0) <= self.DDL_FK_BOOST_THRESHOLD: + ddl_results.append(ref_r) + existing_tables.add(ref_table.lower()) + log.debug(f"Added FK referenced table: {ref_table}") + break + except Exception as e: + log.warning(f"Failed to expand FK tables: {e}") + + return ddl_results[:max_tables] + + def _extract_foreign_key_references(self, ddl_content: str) -> List[str]: + """ + 从DDL中提取外键引用的表名。 + + Args: + ddl_content: DDL内容 + + Returns: + 被引用的表名列表 + """ + referenced = [] + + # 匹配 REFERENCES table_name 模式 + # 支持: REFERENCES table(col), REFERENCES `table`(col), REFERENCES "table"(col) + pattern = r'REFERENCES\s+[`"\[]?(\w+)[`"\]]?\s*\(' + matches = re.findall(pattern, ddl_content, re.IGNORECASE) + referenced.extend(matches) + + # 匹配 FOREIGN KEY ... REFERENCES 模式 + pattern2 = r'FOREIGN\s+KEY\s*\([^)]+\)\s*REFERENCES\s+[`"\[]?(\w+)[`"\]]?' + matches2 = re.findall(pattern2, ddl_content, re.IGNORECASE) + referenced.extend(matches2) + + return list(set(referenced)) + + # ========================================================= + # 索引相关方法(写入向量数据库) + # ========================================================= + + async def index_message( + self, + session_id: int, + message_id: int, + content: str, + role: str = "user" + ) -> None: + """ + 将消息索引到向量数据库。 + + Args: + session_id: 会话 ID + message_id: 消息 ID + content: 消息内容 + role: 角色(user/assistant) + """ + try: + # 生成向量 + embedding = await self.embedding.embed_text(content) + + # 存入向量数据库 + await add_chat_history( + session_id=session_id, + message_id=message_id, + content=content, + embedding=embedding, + role=role + ) + log.debug(f"Indexed message {message_id} for session {session_id}") + except Exception as e: + log.error(f"Failed to index message: {e}") + # 索引失败不应影响主流程,静默失败 + + async def index_knowledge( + self, + project_id: int, + knowledge_id: int, + term: str, + definition: str + ) -> None: + """ + 将领域知识索引到向量数据库。 + + Args: + project_id: 项目 ID + knowledge_id: 知识 ID + term: 术语 + definition: 定义 + """ + try: + # 组合 term 和 definition 生成向量 + combined_text = f"{term}: {definition}" + embedding = await self.embedding.embed_text(combined_text) + + await add_domain_knowledge( + project_id=project_id, + knowledge_id=knowledge_id, + term=term, + definition=definition, + embedding=embedding + ) + log.debug(f"Indexed knowledge {knowledge_id} for project {project_id}") + except Exception as e: + log.error(f"Failed to index knowledge: {e}") + + async def index_ddl( + self, + project_id: int, + ddl_text: str + ) -> None: + """ + 将 DDL 分片索引到向量数据库。 + + DDL 会按表进行分片,每张表的 CREATE TABLE 语句单独索引。 + 这样可以精确检索与用户问题相关的表结构。 + + Args: + project_id: 项目 ID + ddl_text: 完整 DDL 文本(包含所有 CREATE TABLE 语句) + """ + try: + # 分割 DDL 为表级片段 + table_fragments = self._split_ddl_to_tables(ddl_text) + + for table_name, fragment in table_fragments.items(): + embedding = await self.embedding.embed_text(fragment) + await add_ddl_fragment( + project_id=project_id, + table_name=table_name, + ddl_fragment=fragment, + embedding=embedding + ) + + log.info(f"Indexed {len(table_fragments)} table DDLs for project {project_id}") + except Exception as e: + log.error(f"Failed to index DDL: {e}") + + def _split_ddl_to_tables(self, ddl_text: str) -> Dict[str, str]: + """ + 将 DDL 分割为表级片段。 + + Args: + ddl_text: 完整 DDL + + Returns: + Dict[str, str]: {table_name: ddl_fragment} + """ + tables = {} + + # 使用正则匹配 CREATE TABLE 语句 + # 支持 MySQL、PostgreSQL、SQLite 语法 + pattern = r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\[]?(\w+)[`"\]]?\s*\([^;]+\);?' + + matches = re.finditer(pattern, ddl_text, re.IGNORECASE | re.DOTALL) + + for match in matches: + table_name = match.group(1) + ddl_fragment = match.group(0).strip() + tables[table_name] = ddl_fragment + + # 如果正则没匹配到,返回整个 DDL 作为一个片段 + if not tables and ddl_text.strip(): + tables["_full_schema"] = ddl_text + + return tables + + async def index_knowledge_batch( + self, + project_id: int, + knowledge_list: List[Dict[str, Any]] + ) -> None: + """ + 批量索引领域知识。 + + Args: + project_id: 项目 ID + knowledge_list: 知识列表 [{"knowledge_id": int, "term": str, "definition": str}] + """ + if not knowledge_list: + return + + try: + # 批量生成向量 + texts = [f"{k['term']}: {k['definition']}" for k in knowledge_list] + embeddings = await self.embedding.embed_texts(texts) + + # 准备数据 + documents = texts + ids = [f"knowledge_{k['knowledge_id']}" for k in knowledge_list] + metadatas = [ + { + "project_id": project_id, + "knowledge_id": k['knowledge_id'], + "term": k['term'] + } + for k in knowledge_list + ] + + # 批量写入 + await self.vector_db.upsert_documents( + collection_name=VectorDBService.COLLECTION_KNOWLEDGE, + documents=documents, + embeddings=embeddings, + metadatas=metadatas, + ids=ids + ) + + log.info(f"Batch indexed {len(knowledge_list)} knowledge items for project {project_id}") + except Exception as e: + log.error(f"Failed to batch index knowledge: {e}") + + # ========================================================= + # 删除相关方法 + # ========================================================= + + async def delete_session_history(self, session_id: int) -> None: + """删除会话的所有历史向量。""" + await self.vector_db.delete_by_filter( + collection_name=VectorDBService.COLLECTION_HISTORY, + where={"session_id": session_id} + ) + log.info(f"Deleted history vectors for session {session_id}") + + async def delete_project_knowledge(self, project_id: int) -> None: + """删除项目的所有领域知识向量。""" + await self.vector_db.delete_by_filter( + collection_name=VectorDBService.COLLECTION_KNOWLEDGE, + where={"project_id": project_id} + ) + log.info(f"Deleted knowledge vectors for project {project_id}") + + async def delete_project_ddl(self, project_id: int) -> None: + """删除项目的所有 DDL 向量。""" + await self.vector_db.delete_by_filter( + collection_name=VectorDBService.COLLECTION_SCHEMA, + where={"project_id": project_id} + ) + log.info(f"Deleted DDL vectors for project {project_id}") + + async def delete_knowledge_item(self, knowledge_id: int) -> None: + """删除单条领域知识的向量。""" + await self.vector_db.delete_by_ids( + collection_name=VectorDBService.COLLECTION_KNOWLEDGE, + ids=[f"knowledge_{knowledge_id}"] + ) + + +# 全局单例实例 +rag_service = RAGService() + + +# ========================================================= +# 便捷函数(供其他模块直接调用) +# ========================================================= + +async def retrieve_chat_context( + query: str, + project_id: int, + session_id: int, + history_top_k: int = 5, + knowledge_top_k: int = 5, + enable_ddl: bool = True, + ddl_top_k: int = 5 +) -> RAGContext: + """ + 检索聊天所需的上下文(便捷函数)。 + + 专门为 chat_service 设计,默认启用历史、知识和DDL检索。 + DDL检索会自动扩展外键关联表。 + + Args: + query: 用户问题 + project_id: 项目 ID + session_id: 会话 ID + history_top_k: 历史检索数量 + knowledge_top_k: 知识检索数量 + enable_ddl: 是否启用DDL检索 + ddl_top_k: DDL检索数量 + + Returns: + RAGContext: 检索结果 + """ + return await rag_service.retrieve_context( + query=query, + project_id=project_id, + session_id=session_id, + history_top_k=history_top_k, + knowledge_top_k=knowledge_top_k, + ddl_top_k=ddl_top_k, + enable_history=True, + enable_knowledge=True, + enable_ddl=enable_ddl + ) + + +async def index_new_message( + session_id: int, + message_id: int, + content: str, + role: str = "user" +) -> None: + """ + 索引新消息(便捷函数)。 + + Args: + session_id: 会话 ID + message_id: 消息 ID + content: 消息内容 + role: 角色 + """ + await rag_service.index_message( + session_id=session_id, + message_id=message_id, + content=content, + role=role + ) diff --git a/src/backend/app/service/redis_expire_handle_service.py b/src/backend/app/service/redis_expire_handle_service.py new file mode 100644 index 0000000..3950692 --- /dev/null +++ b/src/backend/app/service/redis_expire_handle_service.py @@ -0,0 +1,44 @@ +""" +Redis 过期事件处理。 + +于无请求上下文下处理 Token 过期,构造会话并调用登出逻辑。 +""" + +# backend/app/service/redis_expire_handle_service.py + +from core.database import PsqlHelper +from service.user_service import service_logout +from core.config import config +from core.log import log + +async def service_handle_expire_token(token: str) -> None: + """ + 处理过期的 Token(无请求上下文)。 + + 直接创建数据库会话以响应 Redis 过期事件,并触发登出逻辑。 + + Args: + token (str): 过期的 Token。 + + Returns: + None: 无返回值。 + + Raises: + Exception: 登出或资源清理过程中出现的异常会被记录并吞并。 + """ + log.info(f"Handling expired token: {token}") + + # 虽然这样不好,但是这里没有request对象,只能这样处理,不然结构就要大改 + db_engine = PsqlHelper._get_async_engine(config.db) + db_session = PsqlHelper._get_async_session(db_engine) + + try: + # Process the logout with the directly created session + await service_logout(db_session, token) + log.info(f"Successfully handled expired token: {token}") + except Exception as e: + log.error(f"Error handling expired token {token}: {e}") + finally: + # Ensure the session is closed + await db_session.close() + await db_engine.dispose() diff --git a/src/backend/app/service/report_service.py b/src/backend/app/service/report_service.py new file mode 100644 index 0000000..f0d1e73 --- /dev/null +++ b/src/backend/app/service/report_service.py @@ -0,0 +1,478 @@ +""" +报表服务。 + +基于缓存查询结果与 AI 语句提供报表的创建、读取、更新、删除与导出;并提供 +历史查询数据以构建图表。 +""" + +# backend/app/service/report_service.py + +from typing import List, Any, Dict, Optional +import re +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession as Session +from sqlalchemy import select +from core.exceptions import ItemNotFoundException, ForbiddenException, InvalidOperationException + +from schema import report as schemas +# 导入核心模型 +from models.report import AnalysisReport +from models.query_result import QueryResult +from models.ai_generated_statement import AIGeneratedStatement +from models.project import Project +# 注意:Message 和 Session 我们将在函数内部导入,或者你可以尝试在这里导入 +# 如果报错循环依赖,请保持函数内导入 + + +def _looks_like_iso_date(value: str) -> bool: + # 支持 YYYY-MM-DD 或 YYYY-MM-DDThh:mm:ss(含可选时区) + if not isinstance(value, str): + return False + if not re.match(r"^\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$", value): + return False + try: + # 兼容 'Z' + datetime.fromisoformat(value.replace('Z', '+00:00')) + return True + except Exception: + return False + + +def _infer_field_type(values: List[Any]) -> str: + non_null = [v for v in values if v is not None] + if not non_null: + return 'string' + + # object 优先级最高 + if any(isinstance(v, (dict, list)) for v in non_null): + return 'object' + if any(isinstance(v, bool) for v in non_null): + return 'bool' + if all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in non_null): + return 'number' + if all(isinstance(v, str) for v in non_null) and all(_looks_like_iso_date(v) for v in non_null): + return 'date' + # 混合或不确定:保守降级为 string + return 'string' + + +def _extract_columns(data: List[Dict[str, Any]]) -> List[str]: + if not data: + return [] + # 尽量保持首行字段顺序 + first = data[0] + cols = list(first.keys()) + # 补齐后续行新增字段 + for row in data[1:]: + for k in row.keys(): + if k not in cols: + cols.append(k) + return cols + + +def _infer_fields( + data: List[Dict[str, Any]], + columns: List[str], + sample_size: int = 50, +) -> List[schemas.HistoryQueryField]: + sample = data[:sample_size] + fields: List[schemas.HistoryQueryField] = [] + for col in columns: + col_values = [r.get(col) for r in sample if isinstance(r, dict)] + fields.append(schemas.HistoryQueryField(name=col, type=_infer_field_type(col_values))) + return fields + + +def _reportability_from_fields( + columns: List[str], + fields: List[schemas.HistoryQueryField], +) -> tuple[bool, Optional[str]]: + if len(columns) < 2: + return False, '该查询结果只有一列,无法生成报表。' + + has_number = any(f.type == 'number' for f in fields) + if not has_number: + return False, '该查询结果缺少数值字段,无法作为 Y 轴绘图。' + + has_dimension = any(f.type in ('string', 'date') for f in fields) + if not has_dimension: + return False, '该查询结果缺少维度字段(文本/日期),无法作为 X 轴绘图。' + + return True, None +async def _verify_project_ownership( + db: Session, + project_id: int, + user_id: int, +): + project = await db.get(Project, project_id) + if not project: + raise ItemNotFoundException(message="项目未找到") + + if project.user_id != user_id: + raise ForbiddenException(message="权限拒绝") + + return project + +async def _verify_report_ownership( + db: Session, + report_id: int, + user_id: int, +) -> AnalysisReport: + report = await db.get(AnalysisReport, report_id) + if not report: + raise ItemNotFoundException(message="报表未找到") + + project = await db.get(Project, report.project_id) + if not project or project.user_id != user_id: + raise ForbiddenException(message="权限被拒绝") + + return report + +# -------------------------- +# 报表列表 +# -------------------------- + + +async def get_report_list( + db: Session, + project_id: int, + user_id: int, +)-> List[schemas.Report]: + await _verify_project_ownership(db, project_id, user_id) + """ + 获取项目的报表列表并关联数据源与语句。 + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + + Returns: + List[schemas.Report]: 报表列表。 + + Raises: + HTTPException: 项目不存在时返回 404。 + """ + # [修复问题1]:先检查项目是否存在 + project = await db.get(Project, project_id) + if not project: + raise ItemNotFoundException(message=f"ID 为 {project_id} 的项目未找到") + + stmt = ( + select(AnalysisReport, QueryResult, AIGeneratedStatement) + .join(QueryResult, AnalysisReport.result_id == QueryResult.result_id) + .join(AIGeneratedStatement, QueryResult.statement_id == AIGeneratedStatement.statement_id) + .where(AnalysisReport.project_id == project_id) + .order_by(AnalysisReport.created_at.desc()) + ) + + result = await db.execute(stmt) + rows = result.all() + + reports = [] + for report_obj, result_obj, stmt_obj in rows: + # 处理 chart_config (从数据库JSON转为Pydantic对象) + c_config = None + if report_obj.chart_config: + # 兼容处理:确保 chart_config 是字典 + config_dict = report_obj.chart_config if isinstance(report_obj.chart_config, dict) else {} + # 只有当字典不为空且包含必要的键时才转换 + if config_dict.get('x_axis_key') and config_dict.get('y_axis_key'): + c_config = schemas.ChartConfig(**config_dict) + + reports.append(schemas.Report( + id=str(report_obj.report_id), + project_id=str(project_id), + name=report_obj.name, + type=report_obj.chart_type, + description=report_obj.description, + data=result_obj.result_data if isinstance(result_obj.result_data, list) else [], + chart_config=c_config, + source_query_id=str(report_obj.result_id), + source_query_text=stmt_obj.sql_text, + updated_at=report_obj.updated_at.isoformat() if report_obj.updated_at else report_obj.created_at.isoformat() + )) + + return reports + + +# -------------------------- +# 历史查询记录 +# -------------------------- +async def get_history_queries_service( + db: Session, + project_id: int, + user_id: int, +) -> List[schemas.HistoryQuery]: + await _verify_project_ownership(db, project_id, user_id) + + """ + 获取项目历史查询记录,返回用户原始提问与结果。 + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + + Returns: + List[schemas.HistoryQuery]: 历史查询记录列表。 + + Raises: + HTTPException: 项目不存在时返回 404。 + """ + # [修复问题1]:先检查项目是否存在 + project = await db.get(Project, project_id) + if not project: + raise ItemNotFoundException(message=f"ID 为 {project_id} 的项目未找到") + + # [修复问题2]:局部导入以避免 UnboundLocalError 和循环依赖 + from models.message import Message + from models.session import Session as SessionModel + + # 构造查询:Query Result -> Statement -> Message(assistant) -> Session + # 说明:AIGeneratedStatement.message_id 绑定的是 assistant 消息。 + # 为了展示“用户的提问”,我们会再回查同会话中该 assistant 消息之前最近的一条 user 消息。 + stmt = ( + select(QueryResult, Message) + .join(AIGeneratedStatement, QueryResult.statement_id == AIGeneratedStatement.statement_id) + .join(Message, AIGeneratedStatement.message_id == Message.message_id) + .join(SessionModel, Message.session_id == SessionModel.session_id) + .where(SessionModel.project_id == project_id) + .where(Message.message_type == 'assistant') + .order_by(QueryResult.cached_at.desc()) + ) + + result = await db.execute(stmt) + rows = result.all() + + history: List[schemas.HistoryQuery] = [] + for query_res, msg_obj in rows: + data = query_res.result_data if isinstance(query_res.result_data, list) else [] + # 确保是 [{...}] 的结构,否则前端无法选轴 + normalized: List[Dict[str, Any]] = [r for r in data if isinstance(r, dict)] + columns = _extract_columns(normalized) + fields = _infer_fields(normalized, columns) + reportable, reason = _reportability_from_fields(columns, fields) + + # 回查用户提问:同 session 内,assistant 消息之前最近的一条 user 消息 + query_text = msg_obj.content + try: + stmt_user = ( + select(Message) + .where(Message.session_id == msg_obj.session_id) + .where(Message.message_type == 'user') + .where(Message.created_at <= msg_obj.created_at) + .order_by(Message.created_at.desc()) + .limit(1) + ) + user_res = await db.execute(stmt_user) + user_msg = user_res.scalar_one_or_none() + if user_msg and user_msg.content: + query_text = user_msg.content + except Exception: + # 兜底:保持 assistant 内容(不影响 rows 返回) + pass + + history.append(schemas.HistoryQuery( + id=str(query_res.result_id), + project_id=str(project_id), + query_text=query_text, + timestamp=query_res.cached_at.isoformat() if query_res.cached_at else 'N/A', + result=schemas.HistoryQueryResult(columns=columns, fields=fields, data=normalized), + reportable=reportable, + unreportable_reason=reason, + )) + + return history + + +# -------------------------- +# 创建报表 +# -------------------------- +async def create_report_service( + db: Session, + project_id: int, + user_id: int, + payload: schemas.ReportCreate, +) -> schemas.Report: + """ + 创建报表记录并返回标准响应。 + + Args: + db (Session): 数据库会话。 + project_id (int): 项目 ID。 + payload (schemas.ReportCreate): 报表创建参数。 + + Returns: + schemas.Report: 创建后的报表。 + + Raises: + HTTPException: 项目或数据源不存在。 + """ + # 1. 校验项目 + project = await _verify_project_ownership(db, project_id, user_id) + if not project: + raise ItemNotFoundException(message="项目未找到") + + # 2. 校验数据源 (Query Result) 是否存在 + result_exists = await db.get(QueryResult, payload.query_id) + if not result_exists: + raise ItemNotFoundException(message="查询结果(数据源)未找到") + + data = result_exists.result_data if isinstance(result_exists.result_data, list) else [] + normalized = [r for r in data if isinstance(r, dict)] + columns = _extract_columns(normalized) + fields = _infer_fields(normalized, columns) + reportable, reason = _reportability_from_fields(columns, fields) + if not reportable: + raise InvalidOperationException(message=reason or '该查询结果不适合生成报表。') + + # 3. 创建 AnalysisReport 对象 + new_report = AnalysisReport( + project_id=project_id, + result_id=payload.query_id, + name=payload.report_name, + chart_type=payload.chart_type, + description=payload.description, + chart_config=payload.chart_config.model_dump() if payload.chart_config else None + ) + + db.add(new_report) + await db.commit() + await db.refresh(new_report) + + # 4. 获取关联 SQL 文本用于返回 + stmt_obj = await db.get(AIGeneratedStatement, result_exists.statement_id) + + return schemas.Report( + id=str(new_report.report_id), + project_id=str(project_id), + name=new_report.name, + type=new_report.chart_type, + description=new_report.description, + data=normalized, + chart_config=payload.chart_config, + source_query_id=str(new_report.result_id), + source_query_text=stmt_obj.sql_text if stmt_obj else "", + updated_at=new_report.created_at.isoformat() + ) + + +# -------------------------- +# 删除报表 +# -------------------------- +async def delete_report_service(db: Session, report_id: int, user_id: int) -> bool: + """ + 删除指定报表。 + + Args: + db (Session): 数据库会话。 + report_id (int): 报表 ID。 + + Returns: + bool: 是否删除成功。 + """ + report = await _verify_report_ownership(db, report_id, user_id) + + await db.delete(report) + await db.commit() + return True + + +# -------------------------- +# 更新报表 +# -------------------------- +async def update_report_service( + db: Session, + report_id: int, + user_id: int, + payload: schemas.ReportUpdate, +) -> schemas.Report: + """ + 更新报表基础属性与图表配置。 + + Args: + db (Session): 数据库会话。 + report_id (int): 报表 ID。 + payload (schemas.ReportUpdate): 更新参数。 + + Returns: + schemas.Report: 更新后的报表。 + + Raises: + HTTPException: 报表不存在。 + """ + # 1. 检查是否存在 + report_obj = await _verify_report_ownership(db, report_id, user_id) + + if not report_obj: + raise ItemNotFoundException(message="报表未找到") + + # 2. 更新字段 + if payload.report_name is not None: + report_obj.name = payload.report_name + if payload.chart_type is not None: + report_obj.chart_type = payload.chart_type + if payload.description is not None: + report_obj.description = payload.description + if payload.chart_config is not None: + report_obj.chart_config = payload.chart_config.model_dump() + + await db.commit() + await db.refresh(report_obj) + + # 3. 组装返回数据 + result_obj = await db.get(QueryResult, report_obj.result_id) + stmt_obj = await db.get(AIGeneratedStatement, result_obj.statement_id) + + c_config = None + if report_obj.chart_config: + config_dict = report_obj.chart_config if isinstance(report_obj.chart_config, dict) else {} + if config_dict.get('x_axis_key') and config_dict.get('y_axis_key'): + c_config = schemas.ChartConfig(**config_dict) + + data = result_obj.result_data if isinstance(result_obj.result_data, list) else [] + normalized = [r for r in data if isinstance(r, dict)] + + return schemas.Report( + id=str(report_obj.report_id), + project_id=str(report_obj.project_id), + name=report_obj.name, + type=report_obj.chart_type, + description=report_obj.description, + data=normalized, + chart_config=c_config, + source_query_id=str(report_obj.result_id), + source_query_text=stmt_obj.sql_text, + updated_at=report_obj.updated_at.isoformat() if report_obj.updated_at else "" + ) + + +# -------------------------- +# 导出报表 +# -------------------------- +async def export_report_service( + db: Session, + report_id: int, + format: str, + user_id: int, +) -> dict: + """ + 导出报表,返回下载链接与过期时间。 + + Args: + db (Session): 数据库会话。 + report_id (int): 报表 ID。 + format (str): 导出格式,如 `png` 或 `csv`。 + + Returns: + dict: 包含 `download_url` 和 `expires_at` 的字典。 + + Raises: + HTTPException: 报表不存在。 + """ + # 权限检查(避免路由传参不匹配导致运行时错误) + await _verify_report_ownership(db, report_id, user_id) + + return { + "download_url": f"https://fake-cdn.example.com/reports/{report_id}.{format}", + "expires_at": "2025-12-01T15:00:00Z" + } diff --git a/src/backend/app/service/session_service.py b/src/backend/app/service/session_service.py new file mode 100644 index 0000000..77e8da8 --- /dev/null +++ b/src/backend/app/service/session_service.py @@ -0,0 +1,278 @@ +""" +会话服务。 + +统一处理 Session 的业务逻辑,包括鉴权、越权校验及增删改查操作。 +""" + +# backend/app/service/session_service.py + +from typing import List, Optional + +from fastapi import status +from sqlalchemy.ext.asyncio import AsyncSession + +from crud.crud_session import crud_session +from crud.crud_project import crud_project +from schema.session import SessionCreate, SessionUpdate, SessionResponse +from schema.user import UserMe +from core.log import log +from core.exceptions import ForbiddenException, ItemNotFoundException + + +class SessionService: + """ + 会话业务服务层: + - 统一处理 Session 的业务逻辑 + - 统一处理鉴权与越权校验 + - endpoint 不应直接操作 crud + """ + + @staticmethod + + async def _check_project_owner( + db: AsyncSession, + project_id: int, + user: UserMe + ) -> None: + """ + 校验项目是否属于当前用户。 + + 这是 Session 鉴权的核心逻辑,所有涉及 project_id 的操作必须经过此校验。 + + Args: + db (AsyncSession): 数据库会话。 + project_id (int): 项目 ID。 + user (UserMe): 当前用户对象。 + + Raises: + ForbiddenException: 权限不足时抛出 403。 + """ + log.info("检查项目") + project = await crud_project.get(db=db, project_id=project_id) + log.info("检查项目2") + if not project or project.user_id != user.user_id: + log.info("检查项目3") + raise ForbiddenException(message="Project access forbidden") + + @staticmethod + async def _check_session_owner( + db: AsyncSession, + session_id: int, + user: UserMe + ): + """ + 校验会话是否属于当前用户。 + + 通过 session → project → user 的链路进行校验,防止越权访问。 + + Args: + db (AsyncSession): 数据库会话。 + session_id (int): 会话 ID。 + user (UserMe): 当前用户对象。 + + Returns: + Session: 会话对象。 + + Raises: + ItemNotFoundException: 会话不存在或权限不足时抛出。 + """ + session = await crud_session.get(db=db, session_id=session_id) + if not session: + raise ItemNotFoundException(message="Session not found") + + await SessionService._check_project_owner( + db=db, + project_id=session.project_id, + user=user + ) + return session + + @staticmethod + async def get_sessions( + db: AsyncSession, + user: UserMe, + project_id: Optional[int], + skip: int, + limit: int + ) -> List[SessionResponse]: + """ + 获取会话列表。 + + 支持按 project_id 过滤,强制校验 project 属于当前用户。 + + Args: + db (AsyncSession): 数据库会话。 + user (UserMe): 当前用户对象。 + project_id (Optional[int]): 项目 ID 过滤。 + skip (int): 跳过数量。 + limit (int): 返回限制。 + + Returns: + List[SessionResponse]: 会话列表。 + """ + if project_id: + await SessionService._check_project_owner(db, project_id, user) + return await crud_session.get_by_project( + db=db, + project_id=project_id, + skip=skip, + limit=limit + ) + + # 如果不传 project_id,则返回当前用户所有项目下的会话 + return await crud_session.get_by_user( + db=db, + user_id=user.user_id, + skip=skip, + limit=limit + ) + + @staticmethod + async def get_sessions_count( + db: AsyncSession, + user: UserMe, + project_id: Optional[int] + ) -> int: + """ + 获取会话总数。 + + 支持按 project_id 过滤,强制校验 project 属于当前用户。 + + Args: + db (AsyncSession): 数据库会话。 + user (UserMe): 当前用户对象。 + project_id (Optional[int]): 项目 ID 过滤。 + + Returns: + int: 会话总数。 + """ + if project_id: + await SessionService._check_project_owner(db, project_id, user) + return await crud_session.count_by_project( + db=db, + project_id=project_id + ) + + # 如果不传 project_id,则返回当前用户所有项目下的会话总数 + return await crud_session.count_by_user( + db=db, + user_id=user.user_id + ) + + @staticmethod + async def create_session( + db: AsyncSession, + user: UserMe, + session_in: SessionCreate + ) -> SessionResponse: + """ + 创建新会话。 + + - 校验 project 属于当前用户 + - 统一由后端控制可写字段 + + Args: + db (AsyncSession): 数据库会话。 + user (UserMe): 当前用户对象。 + session_in (SessionCreate): 会话创建参数。 + + Returns: + SessionResponse: 创建后的会话。 + """ + log.info("创建会话2") + await SessionService._check_project_owner( + db=db, + project_id=session_in.project_id, + user=user + ) + log.info("创建会话3") + return await crud_session.create( + db=db, + **session_in.dict() + ) + + @staticmethod + async def get_session( + db: AsyncSession, + user: UserMe, + session_id: int + ) -> SessionResponse: + """ + 获取单个会话详情。 + + Args: + db (AsyncSession): 数据库会话。 + user (UserMe): 当前用户对象。 + session_id (int): 会话 ID。 + + Returns: + SessionResponse: 会话详情。 + """ + return await SessionService._check_session_owner( + db=db, + session_id=session_id, + user=user + ) + + @staticmethod + async def update_session( + db: AsyncSession, + user: UserMe, + session_id: int, + session_in: SessionUpdate + ) -> SessionResponse: + """ + 更新会话信息(如重命名)。 + + Args: + db (AsyncSession): 数据库会话。 + user (UserMe): 当前用户对象。 + session_id (int): 会话 ID。 + session_in (SessionUpdate): 会话更新参数。 + + Returns: + SessionResponse: 更新后的会话。 + """ + session = await SessionService._check_session_owner( + db=db, + session_id=session_id, + user=user + ) + + update_data = session_in.dict(exclude_unset=True) + return await crud_session.update( + db=db, + db_obj=session, + **update_data + ) + + @staticmethod + async def delete_session( + db: AsyncSession, + user: UserMe, + session_id: int + ) -> bool: + """ + 删除会话。 + + Args: + db (AsyncSession): 数据库会话。 + user (UserMe): 当前用户对象。 + session_id (int): 会话 ID。 + + Returns: + bool: 是否删除成功。 + """ + await SessionService._check_session_owner( + db=db, + session_id=session_id, + user=user + ) + return await crud_session.remove( + db=db, + session_id=session_id + ) + + +# 对外暴露的单例(保持与其他 service 风格一致) +session_service = SessionService() diff --git a/src/backend/app/service/sqlite_service.py b/src/backend/app/service/sqlite_service.py new file mode 100644 index 0000000..1edea9b --- /dev/null +++ b/src/backend/app/service/sqlite_service.py @@ -0,0 +1,184 @@ +""" +SQLite 服务模块。 + +提供创建用户、授予权限、确保引擎存在等功能。 +SQLite是文件型数据库,所以这里主要是管理数据库文件的创建和引擎初始化。 +""" + +# backend/app/service/sqlite_service.py + +import os +from pathlib import Path +from typing import Optional, Union +import aiosqlite + +from core.config import config +from models.database_instance import DatabaseInstance +from sqlite.sqlite_database import SQLiteHelper +from sqlite.sqlite_execute import deploy_sqlite_ddl +from core.exceptions import DatabaseOperationFailedException, ItemNotFoundException + + +async def create_sqlite_database(db_name: str, user_id: Optional[int] = None) -> bool: + """ + 创建SQLite数据库文件 + + Args: + db_name: 数据库名称(将作为文件名) + user_id: 用户ID,用于隔离不同用户的数据库文件 + + Returns: + bool: 创建成功返回True,否则返回False + """ + sqlite_config = config.sqlite + + # 使用配置的路径,支持用户隔离 + db_file_path = sqlite_config.get_database_path(db_name, user_id) + + # 如果文件已存在,直接返回成功 + if os.path.exists(db_file_path): + return True + + # 确保目录存在 + Path(db_file_path).parent.mkdir(parents=True, exist_ok=True) + + # 创建空的数据库文件 + try: + async with aiosqlite.connect(db_file_path) as db: + await db.execute("CREATE TABLE IF NOT EXISTS dummy_table (id INTEGER PRIMARY KEY);") + await db.commit() + + # 删除临时表 + async with aiosqlite.connect(db_file_path) as db: + await db.execute("DROP TABLE dummy_table;") + await db.commit() + + return True + except Exception as e: + raise DatabaseOperationFailedException(operation=f"创建SQLite数据库 {db_file_path},错误:{str(e)}") + + +async def ensure_sqlite_engine(db_name: str, instance_id: int, user_id: Optional[int] = None) -> bool: + """ + 确保SQLite引擎存在 + + Args: + db_name: 数据库名称 + instance_id: 实例ID + user_id: 用户ID,用于隔离不同用户的数据库文件 + + Returns: + bool: 引擎存在或成功创建返回True,否则返回False + """ + if SQLiteHelper.is_user_engine_exists(instance_id): + return True + + try: + sqlite_config = config.sqlite + await SQLiteHelper.init_user_engine(sqlite_config, instance_id, db_name, user_id) + return True + except Exception as e: + raise DatabaseOperationFailedException(operation=f"初始化SQLite引擎,错误:{str(e)}") + + +async def ensure_sqlite_user_and_engine(db_name: str, db_username: str, db_password: str, instance_id: int, user_id: Optional[int] = None) -> bool: + """ + 确保SQLite用户和引擎存在(SQLite是文件型数据库,这里主要是确保引擎初始化) + + Args: + db_name: 数据库名称 + db_username: 用户名(SQLite不需要,但为了兼容性保留) + db_password: 密码(SQLite不需要,但为了兼容性保留) + instance_id: 实例ID + user_id: 用户ID,用于隔离不同用户的数据库文件 + + Returns: + bool: 成功返回True,否则返回False + """ + # 创建数据库文件(如果不存在) + await create_sqlite_database(db_name, user_id=user_id) + + # 确保引擎存在 + await ensure_sqlite_engine(db_name, instance_id, user_id) + return True + + +async def deploy_sqlite_database(db_name: str, statements: list[str], instance_id: int) -> bool: + """ + 部署SQLite数据库 + + Args: + db_name: 数据库名称 + statements: SQL语句列表 + instance_id: 实例ID + + Returns: + bool: 部署成功返回True,否则返回False + """ + try: + # 确保引擎存在 + await ensure_sqlite_engine(db_name, instance_id) + + # 执行DDL语句 + sqlite_config = config.sqlite + await deploy_sqlite_ddl(db_name, statements, sqlite_config) + return True + except Exception as e: + raise DatabaseOperationFailedException(operation=f"部署SQLite数据库,错误:{str(e)}") + + +async def delete_sqlite_database(db_name: str, db_path: Optional[str] = None) -> bool: + """ + 删除SQLite数据库文件 + + Args: + db_name: 数据库名称 + db_path: 数据库文件路径(可选,如果不提供则使用配置中的默认路径) + + Returns: + bool: 删除成功返回True,否则返回False + """ + if db_path is None: + sqlite_config = config.sqlite + db_path = sqlite_config.db_path + + db_file_path = os.path.join(db_path, f"{db_name}.db") + + if os.path.exists(db_file_path): + try: + os.remove(db_file_path) + return True + except Exception as e: + raise DatabaseOperationFailedException(operation=f"删除SQLite数据库文件 {db_file_path},错误:{str(e)}") + else: + raise ItemNotFoundException(message=f"SQLite数据库文件不存在: {db_file_path}") + +async def execute_sqlite_sql_with_user_check( + sql: str, + sql_type: str, + instance_obj: DatabaseInstance, + user_id: int +) -> Optional[list]: + """ + 执行 SQL 前先确保 SQLite 引擎已初始化且路径正确(基于 user_id)。 + """ + # 1. 确保引擎已初始化(内部会处理文件路径隔离) + try: + await ensure_sqlite_user_and_engine( + db_name=instance_obj.db_name, + db_username=instance_obj.db_username, + db_password=instance_obj.db_password, + instance_id=instance_obj.instance_id, + user_id=user_id + ) + except Exception as e: + raise DatabaseOperationFailedException(operation=f"确保实例 {instance_obj.instance_id} 的SQLite引擎,错误:{str(e)}") + + # 2. 调用执行器 + from sqlite.sqlite_execute import execute_dql_user, execute_dml_user + if sql_type == "SELECT": + return await execute_dql_user(sql, instance_obj) + else: + # DML 操作处理 + res = await execute_dml_user(sql, instance_obj) + return [res] # 返回列表以保持接口一致性 \ No newline at end of file diff --git a/src/backend/app/service/user_service.py b/src/backend/app/service/user_service.py new file mode 100644 index 0000000..14e4999 --- /dev/null +++ b/src/backend/app/service/user_service.py @@ -0,0 +1,921 @@ +""" +用户服务。 + +处理注册、登录、登出、Token 缓存、个人资料与设置、登录历史等;封装校验、 +安全与 CRUD 编排。 +""" + +# backend/app/service/user_service.py + +from typing import Optional, List, Dict, Any # 确保导入了所有类型 +from fastapi import Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError + +# 隐式绝对导入 (核心依赖) +from crud.crud_user_login_history import crud_login_history +from models.user_login_history import UserLoginHistory +from crud.crud_user_account import crud_user_account +from models.user_account import UserAccount +from core import exceptions +from core.auth import get_password_hash, verify_password, decode_jwt_token # 补充 decode_jwt_token 供 logout 使用 +from core.log import log +from core.exceptions import ValidationException +from redis_client.redis import get_redis +from core.config import config +from service import email_service # 导入 email_service 模块本身 +from schema import user as schemas # 导入 User Schemas +from redis_client.redis_keys import redis_key_manager +from redis_client.cache_service import cache_service + + +# ---------------------------------------------------------------------- +# 辅助函数 (Service Internal) +# ---------------------------------------------------------------------- + +async def check_email_exists(db: AsyncSession, email: str) -> bool: + """ + 检查邮箱是否已存在。 + + Args: + db (AsyncSession): 数据库会话。 + email (str): 邮箱地址。 + + Returns: + bool: True 表示已存在。 + """ + log.info(f"Checking email {email} exists") + existing_user = await crud_user_account.get_by_email(db, email) + if existing_user: + return True + return False + + +async def check_username_exists(db: AsyncSession, username: str) -> bool: + """ + 检查用户名是否已存在。 + + Args: + db (AsyncSession): 数据库会话。 + username (str): 用户名。 + + Returns: + bool: True 表示已存在。 + """ + existing_user = await crud_user_account.get_by_username(db, username) + if existing_user: + return True + return False + + +async def service_check_user_exists(db: AsyncSession, username_or_email: str) -> Optional[UserAccount]: + """ + 根据用户名或邮箱查询用户。 + + Args: + db (AsyncSession): 数据库会话。 + username_or_email (str): 用户名或邮箱。 + + Returns: + Optional[UserAccount]: 用户对象或 None。 + + Raises: + exceptions.DatabaseOperationFailedException: 查询异常。 + """ + try: + user: Optional[UserAccount] = await crud_user_account.get_by_username_or_email( + db=db, username_or_email=username_or_email + ) + return user + except SQLAlchemyError: + raise exceptions.DatabaseOperationFailedException("query user") + + +# ---------------------------------------------------------------------- +# 认证与用户注册 (Auth & Registration) +# ---------------------------------------------------------------------- + +async def service_register_user( + db: AsyncSession, + username: str, + email: str, + password: str, + code: str +) -> Optional[UserAccount]: + """ + 注册新用户。 + + Args: + db (AsyncSession): 数据库会话。 + username (str): 用户名。 + email (str): 邮箱。 + password (str): 明文密码。 + code (str): 邮箱验证码。 + + Returns: + Optional[UserAccount]: 新建的用户对象。 + + Raises: + exceptions.UsernameHasBeenRegisteredException: 用户名已存在。 + exceptions.DatabaseOperationFailedException: 创建失败。 + """ + log.info(f"Registering user {username}") + + # 验证码验证 + if not await email_service.service_verify_code(email, code): + raise exceptions.CodeInvalidException() + + # 验证用户名是否存在 + if await check_username_exists(db, username): + raise exceptions.UsernameHasBeenRegisteredException() + + # 加密密码 + hashed_password = get_password_hash(password) + + # 创建用户账户 + try: + new_user = await crud_user_account.create( + db, + username=username, + email=email, + password_hash=hashed_password # 字段名修正为 password_hash + ) + return new_user + except SQLAlchemyError: + raise exceptions.DatabaseOperationFailedException("create user") + + +async def service_login_with_record( + db: AsyncSession, + request: Request, + username: str, + password: str +) -> Optional[UserAccount]: + """ + 用户登录并记录登录日志。 + + Args: + db (AsyncSession): 数据库会话。 + request (Request): HTTP请求对象。 + username (str): 用户名。 + password (str): 明文密码。 + + Returns: + Optional[UserAccount]: 登录成功的用户对象。 + + Raises: + exceptions.UserNotFoundException: 用户不存在。 + exceptions.PasswordInvalidException: 密码错误。 + exceptions.UserStatusForbiddenException: 用户状态异常。 + """ + # 从请求中获取登录信息 + client_ip = request.client.host + user_agent = request.headers.get("User-Agent", "") + device_info = request.headers.get("X-Device-Info", "") + + user = await service_check_user_exists(db, username) + + if not user: + log.info(f"Login failed: user {username} not found") + + # 记录登录失败日志 + await create_login_record( + db=db, + user_id=None, # 用户不存在,所以user_id为None + ip_address=client_ip, + login_status="failed", + user_agent=user_agent, + device_info=device_info, + failure_reason="User not found" + ) + + raise exceptions.UserNotFoundException() + + # 用户状态检查:只有 banned(封禁)状态禁止登录 + # suspended(异常)状态只是标记,用户仍可正常登录,管理员可查看并决定是否封禁 + if user.status == "banned": + log.error(f"User {user.user_id} is banned, login denied") + + # 记录登录失败日志 + await create_login_record( + db=db, + user_id=user.user_id, + ip_address=client_ip, + login_status="failed", + user_agent=user_agent, + device_info=device_info, + failure_reason="User is banned" + ) + + raise exceptions.UserStatusForbiddenException(status=user.status, user_id=user.user_id) + + # 如果用户是 suspended 状态,记录日志但允许登录 + if user.status == "suspended": + log.warning(f"User {user.user_id} is suspended (marked as abnormal), but allowed to login") + + # 密码是否正确 + if not verify_password(password, user.password_hash): + log.error(f"User {user.user_id} password is invalid") + + # 记录登录失败日志 + await create_login_record( + db=db, + user_id=user.user_id, + ip_address=client_ip, + login_status="failed", + user_agent=user_agent, + device_info=device_info, + failure_reason="Password invalid" + ) + + # 管理员账户不受密码错误次数限制 + if user.is_admin: + log.info(f"Admin user {user.user_id} password invalid, but no failure tracking for admins") + raise exceptions.PasswordInvalidException(user_id=user.user_id, failed_attempts=0, custom_message="用户名或密码不正确") + + # 普通用户:记录密码错误次数,检查是否达到三次阈值 + from core.security import login_failure_tracker, BlacklistManager, ViolationLogger + fail_count, should_suspend = await login_failure_tracker.record_failed_attempt(user.user_id) + + if should_suspend: + # 密码连续错误3次直接触发标记(不通过累计违规记录) + # 先标记用户为异常状态 + await BlacklistManager.suspend_user( + db=db, + user_id=user.user_id, + reason=f"连续 {fail_count} 次输错密码,系统自动标记为异常" + ) + + # 再记录违规日志(仅用于审计追踪,不触发累计检查) + await ViolationLogger.log_violation( + db=db, + user_id=user.user_id, + event_type=ViolationLogger.EVENT_MULTIPLE_FAILED_LOGINS, + event_description=f"连续 {fail_count} 次输错密码,账户被标记为异常", + risk_level=ViolationLogger.RISK_HIGH, + ip_address=client_ip, + auto_check_suspend=False # 已手动处理标记,跳过累计检查 + ) + await db.commit() + + log.warning(f"User {user.user_id} has been suspended due to {fail_count} failed login attempts") + + # 根据失败次数确定错误消息 + if fail_count >= 3: + custom_message = f"密码错误次数过多,账户已被标记为异常,请注意账户安全" + elif fail_count > 0: + custom_message = f"用户名或密码不正确,还剩 {3 - fail_count} 次尝试机会" + else: + custom_message = "用户名或密码不正确" + + raise exceptions.PasswordInvalidException(user_id=user.user_id, failed_attempts=fail_count, custom_message=custom_message) + + # 登录成功,重置密码错误计数(仅普通用户) + if not user.is_admin: + from core.security import login_failure_tracker + await login_failure_tracker.reset_failed_count(user.user_id) + + # 处理旧的未登出会话 + old_login_record = await crud_login_history.get_latest_unlogout_record(db, user.user_id) + if old_login_record: + log.info(f"Found old unlogout record {old_login_record.login_id} for user {user.user_id}, forcing logout") + await crud_login_history.update_logout_info(db, old_login_record) + + # 更新最后登录时间 + try: + from datetime import datetime, timezone + updated_user = await crud_user_account.update( + db, + user, + last_login_at=datetime.now(timezone.utc) + ) + log.info(f"Updated last_login_at for user {user.user_id}") + user = updated_user + except SQLAlchemyError as e: + log.warning(f"Failed to update last_login_at for user {user.user_id}: {e}") + # 不抛出异常,允许登录继续进行 + + # 记录登录成功日志 + await create_login_record( + db=db, + user_id=user.user_id, + ip_address=client_ip, + login_status="success", + user_agent=user_agent, + device_info=device_info, + ) + + log.info(f"User {user.user_id} login successfully") + + # 登录成功后,检查 IP 频繁变更情况 + try: + from core.security import RemoteLoginDetector, ViolationLogger + is_ip_changed_frequently, ip_change_desc = await RemoteLoginDetector.check_frequent_ip_changes( + user.user_id, + client_ip + ) + if is_ip_changed_frequently: + await ViolationLogger.log_violation( + db, + user.user_id, + ViolationLogger.EVENT_FREQUENT_REMOTE_LOGIN, + ip_change_desc, + risk_level=ViolationLogger.RISK_HIGH, + ip_address=client_ip, + client_user_agent=user_agent + ) + await db.commit() + log.warning(f"User {user.user_id} marked as abnormal due to frequent IP changes during login") + except Exception as e: + log.error(f"Login IP check failed for user {user.user_id}: {e}") + + return user + + +async def service_save_token_in_redis(token: str) -> bool: + """ + 将 Token 缓存到 Redis。 + + Args: + token (str): 访问 Token。 + + Returns: + bool: 是否缓存成功。 + """ + redis = get_redis() + if not redis: + log.warning("Redis connection not available for token storage") + return False + + try: + key = redis_key_manager.get_token_key(token) + await redis.set(key, "1", ex=config.jwt.token_expire_time_seconds) + log.info(f"Save token to redis_client") + return True + except Exception as e: + log.error(f"Error saving token to Redis: {e}") + # Redis失败时不影响主流程,返回False表示缓存失败但业务可继续 + return False + + +async def service_set_user_online_status(user_id: int, is_online: bool = True) -> bool: + """ + 设置用户在线状态到 Redis。 + + Args: + user_id (int): 用户 ID。 + is_online (bool): 是否在线。 + + Returns: + bool: 是否设置成功。 + """ + redis = get_redis() + if redis: + if is_online: + # 设置用户在线,过期时间为 token 过期时间的 1.5 倍 + expire_time = int(config.jwt.token_expire_time_seconds * 1.5) + await redis.set(f"user_online:{user_id}", "1", ex=expire_time) + log.info(f"Set user {user_id} online status to {is_online}") + else: + # 删除在线状态 + await redis.delete(f"user_online:{user_id}") + log.info(f"Set user {user_id} online status to {is_online}") + return True + return False + + +async def service_get_user_online_status(user_id: int) -> bool: + """ + 获取用户在线状态。 + + Args: + user_id (int): 用户 ID。 + + Returns: + bool: 是否在线。 + """ + redis = get_redis() + if redis: + result = await redis.get(f"user_online:{user_id}") + return result is not None + return False + + +async def service_abolish_token_in_redis(token: str) -> bool: + """ + 从 Redis 中删除/废除 Token。 + + Args: + token (str): 访问 Token。 + + Returns: + bool: 是否删除成功。 + """ + redis = get_redis() + if not redis: + log.warning("Redis connection not available for token abolition") + return False + + try: + key = redis_key_manager.get_token_key(token) + await redis.delete(key) + log.info(f"Abolish token in redis_client") + return True + except Exception as e: + log.error(f"Error abolishing token in Redis: {e}") + # Redis失败时不影响主流程,返回False表示删除失败但业务可继续 + return False + + +async def create_login_record( + db: AsyncSession, + user_id: int | None, + ip_address: str, + login_status: str, + user_agent: str | None = None, + failure_reason: str | None = None, + device_info: dict | None = None, +) -> UserLoginHistory: + """ + 创建登录记录。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int | None): 用户 ID。 + ip_address (str): 登录 IP。 + login_status (str): 登录状态。 + user_agent (str | None): UA 字符串。 + failure_reason (str | None): 失败原因。 + device_info (dict | None): 设备信息。 + + Returns: + UserLoginHistory: 新建的登录记录。 + + Raises: + ValidationException: 必填项缺失。 + """ + log.info(f"user_id={user_id}, ip_address={ip_address}, login_status={login_status}") + if not ip_address or not login_status: + raise ValidationException("用户ID、IP地址、登录状态不能为空") + + return await crud_login_history.create_login_record( + db=db, + user_id=user_id, + ip_address=ip_address, + login_status=login_status, + user_agent=user_agent, + failure_reason=failure_reason, + device_info=device_info + ) + + +async def service_logout( + db: AsyncSession, + token: str +) -> bool: + """ + 用户登出。 + + Args: + db (AsyncSession): 数据库会话。 + token (str): 访问 Token。 + + Returns: + bool: 是否登出成功。 + """ + # 从Redis中删除token,使其失效 + if not await service_abolish_token_in_redis(token): + log.error(f"无法废除Redis中的令牌 {token}") + # 注意:即使 Redis 删除失败,通常也应该继续记录登出日志 + + # 获取用户ID + log.info(f"Getting user id from token {token}") + try: + user_id = decode_jwt_token(token) + except Exception as e: + log.error(f"Error decoding token during logout: {e}") + return False + + # 设置用户离线状态 + await service_set_user_online_status(user_id, is_online=False) + + # 删除用户信息缓存 + cache_key = redis_key_manager.get_user_info_key(user_id) + await cache_service.delete(cache_key) + + # 查找用户最新的未登出登录记录 + latest_record = await crud_login_history.get_latest_unlogout_record(db, user_id) + + # 增加判空逻辑 + if latest_record: + log.info(f"Updating logout info for record {latest_record.login_id}") # 这里访问属性是安全的 + if not latest_record.logout_time: + await crud_login_history.update_logout_info(db, latest_record) + else: + # 如果没找到记录(可能用户从未登录,或者数据被清理),仅记录日志即可,不抛错 + log.warning(f"No active login record found for user {user_id} during logout.") + + log.info(f"User {user_id} logged out successfully") + return True + +async def force_logout_user_sessions(user_id: int) -> bool: + """ + 强制登出用户的所有活跃会话。 + + 此方法用于在封禁用户时,强制使其所有活跃会话失效。 + + Args: + user_id (int): 用户 ID。 + + Returns: + bool: 是否登出成功。 + """ + # 1. 删除用户信息缓存 + cache_key = redis_key_manager.get_user_info_key(user_id) + await cache_service.delete(cache_key) + + # 2. 设置用户为离线状态 + await service_set_user_online_status(user_id, is_online=False) + + # 3. 删除用户的所有JWT令牌 + redis = get_redis() + if redis: + # 生成token键的前缀 + token_prefix = redis_key_manager.get_token_key("").rsplit(":", 1)[0] + ":" + + # 使用SCAN命令遍历所有token键,避免阻塞Redis + cursor = 0 + deleted_count = 0 + + while True: + cursor, keys = await redis.scan(cursor=cursor, match=f"{token_prefix}*", count=100) + + for key in keys: + # 从键中提取token值 + token = key.rsplit(":", 1)[-1] + + try: + # 解码token获取用户ID + decoded_user_id = decode_jwt_token(token) + + # 如果token属于目标用户,则删除 + if decoded_user_id == user_id: + await redis.delete(key) + deleted_count += 1 + except Exception: + # 忽略无效的token + continue + + # 如果cursor为0,表示遍历完成 + if cursor == 0: + break + + if deleted_count > 0: + log.info(f"Deleted {deleted_count} JWT tokens for user {user_id}") + + log.info(f"User {user_id} has been forced logout from all sessions") + return True + +async def get_current_user( + db: AsyncSession, + username_or_email: str, +) -> UserAccount: + """ + 获取当前用户。 + + Args: + db (AsyncSession): 数据库会话。 + username_or_email (str): 用户名或邮箱。 + + Returns: + UserAccount: 用户对象。 + + Raises: + exceptions.UserNotFoundException: 用户不存在。 + exceptions.UserStatusForbiddenException: 用户状态异常。 + exceptions.DatabaseOperationFailedException: 查询失败。 + """ + try: + user: Optional[UserAccount] = await crud_user_account.get_by_username_or_email( + db=db, username_or_email=username_or_email + ) + except SQLAlchemyError: + raise exceptions.DatabaseOperationFailedException("query user") + + if not user: + raise exceptions.UserNotFoundException() + + # 只有 banned 状态禁止访问,suspended 状态可正常使用 + if user.status == "banned": + raise exceptions.UserStatusForbiddenException(status=user.status) + + return user + + +# ---------------------------------------------------------------------- +# 用户设置模块 (User Settings) +# ---------------------------------------------------------------------- + +async def get_user_me_service(db: AsyncSession, user_id: int) -> schemas.UserMe: + """ + 获取当前登录用户的详细信息,带缓存。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户 ID。 + + Returns: + schemas.UserMe: 用户信息。 + + Raises: + exceptions.UserNotFoundException: 用户不存在。 + exceptions.DatabaseOperationFailedException: 查询失败。 + """ + log.info(f"Fetching profile for user {user_id}") + + # 生成缓存键 + cache_key = redis_key_manager.get_user_info_key(user_id) + + # 定义从数据库获取用户信息的函数 + async def fetch_user_data(): + log.info(f"Cache miss for user {user_id}, fetching from database") + user_orm = await crud_user_account.get(db, user_id) + if not user_orm: + raise exceptions.UserNotFoundException() + return schemas.UserMe.model_validate(user_orm) + + # 使用缓存服务获取或设置数据,TTL设为300秒 + cache_result = await cache_service.get_or_set(cache_key, fetch_user_data, ttl=300) + + # 检查缓存结果是否有效(None 或空字符串都视为无效) + if cache_result.data is None or cache_result.data == "": + raise exceptions.UserNotFoundException() + + # 确保返回的是UserMe模型对象 + if isinstance(cache_result.data, dict): + return schemas.UserMe(**cache_result.data) + + return cache_result.data + + +async def update_password_service( + db: AsyncSession, + user_id: int, + password_data: schemas.UserUpdatePassword +) -> schemas.UserMe: + """ + 验证旧密码并更新为新密码。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户 ID。 + password_data (schemas.UserUpdatePassword): 密码更新参数。 + + Returns: + schemas.UserMe: 更新后的用户信息。 + + Raises: + exceptions.UserNotFoundException: 用户不存在。 + exceptions.PasswordInvalidException: 旧密码错误。 + exceptions.DatabaseOperationFailedException: 更新失败。 + """ + log.info(f"Updating password for user {user_id}") + try: + db_user = await crud_user_account.get(db, user_id) + + if not db_user: + raise exceptions.UserNotFoundException() + + if not verify_password(password_data.old_password, db_user.password_hash): + raise exceptions.PasswordInvalidException(user_id=user_id, custom_message="旧密码不正确") + + new_hashed_password = get_password_hash(password_data.new_password) + + updated_orm = await crud_user_account.update( + db, + db_user, + password_hash=new_hashed_password + ) + + # 清除用户信息缓存 + cache_key = redis_key_manager.get_user_info_key(user_id) + await cache_service.delete(cache_key) + + return schemas.UserMe.model_validate(updated_orm) + + except SQLAlchemyError: + raise exceptions.DatabaseOperationFailedException("update password") + + +async def update_username_service( + db: AsyncSession, + user_id: int, + username_data: schemas.UserUpdateUsername +) -> schemas.UserMe: + """ + 更新用户名并检查唯一性。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户 ID。 + username_data (schemas.UserUpdateUsername): 用户名更新参数。 + + Returns: + schemas.UserMe: 更新后的用户信息。 + + Raises: + exceptions.UsernameHasBeenRegisteredException: 用户名重复。 + exceptions.UserNotFoundException: 用户不存在。 + exceptions.DatabaseOperationFailedException: 更新失败。 + """ + log.info(f"Updating username for user {user_id} to {username_data.username}") + try: + db_user = await crud_user_account.get(db, user_id) + + if not db_user: + raise exceptions.UserNotFoundException() + + # 如果用户名没有变化,直接返回 + if db_user.username == username_data.username: + return schemas.UserMe.model_validate(db_user) + + # 检查新用户名是否已被其他用户使用 + if await check_username_exists(db, username_data.username): + raise exceptions.UsernameHasBeenRegisteredException() + + updated_orm = await crud_user_account.update( + db, + db_user, + username=username_data.username + ) + + # 清除用户信息缓存 + cache_key = redis_key_manager.get_user_info_key(user_id) + await cache_service.delete(cache_key) + + return schemas.UserMe.model_validate(updated_orm) + + except SQLAlchemyError: + raise exceptions.DatabaseOperationFailedException("update username") + + +async def update_avatar_service( + db: AsyncSession, + user_id: int, + avatar_data: schemas.UserUpdateAvatar +) -> schemas.UserMe: + """ + 更新用户头像 URL。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户 ID。 + avatar_data (schemas.UserUpdateAvatar): 头像更新参数。 + + Returns: + schemas.UserMe: 更新后的用户信息。 + + Raises: + exceptions.UserNotFoundException: 用户不存在。 + exceptions.DatabaseOperationFailedException: 更新失败。 + """ + log.info(f"Updating avatar for user {user_id}") + try: + db_user = await crud_user_account.get(db, user_id) + if not db_user: + raise exceptions.UserNotFoundException() + + updated_orm = await crud_user_account.update( + db, + db_user, + avatar_url=avatar_data.avatar_url + ) + + # 清除用户信息缓存 + cache_key = redis_key_manager.get_user_info_key(user_id) + await cache_service.delete(cache_key) + + return schemas.UserMe.model_validate(updated_orm) + except SQLAlchemyError: + raise exceptions.DatabaseOperationFailedException("update avatar") + + +async def request_update_email_service( + db: AsyncSession, + user_id: int, + request_data: schemas.UserUpdateEmailRequest +) -> bool: + """ + 检查新邮箱是否可用,并发送验证码。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户 ID。 + request_data (schemas.UserUpdateEmailRequest): 邮箱更新请求参数。 + + Returns: + bool: 是否发送成功。 + + Raises: + exceptions.EmailHasBeenRegisteredException: 邮箱已被注册。 + exceptions.DatabaseOperationFailedException: 检查失败。 + """ + log.info(f"User {user_id} requesting email change to {request_data.new_email}") + try: + if await check_email_exists(db, request_data.new_email): + raise exceptions.EmailHasBeenRegisteredException() + + # 2. 调用 Email Service 发送验证码(模拟) + # await email_service.service_send_email_code(request_data.new_email) + # 取消注释并且更正函数名 + await email_service.service_send_verification_code(request_data.new_email) + log.info(f"Verification code sent to {request_data.new_email}") + return True + except SQLAlchemyError: + raise exceptions.DatabaseOperationFailedException("check email existence") + + +async def confirm_update_email_service( + db: AsyncSession, + user_id: int, + confirm_data: schemas.UserUpdateEmailConfirm +) -> schemas.UserMe: + """ + 验证验证码并最终更新邮箱。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户 ID。 + confirm_data (schemas.UserUpdateEmailConfirm): 邮箱确认参数。 + + Returns: + schemas.UserMe: 更新后的用户信息。 + + Raises: + exceptions.CodeInvalidException: 验证码错误。 + exceptions.UserNotFoundException: 用户不存在。 + exceptions.DatabaseOperationFailedException: 更新失败。 + """ + log.info(f"User {user_id} confirming new email {confirm_data.new_email}") + + # 1. 业务逻辑:校验验证码 + if not await email_service.service_verify_code(confirm_data.new_email, confirm_data.code): + raise exceptions.CodeInvalidException() + + try: + db_user = await crud_user_account.get(db, user_id) + if not db_user: + raise exceptions.UserNotFoundException() + + # 3. 调用 CRUD 更新邮箱 + updated_orm = await crud_user_account.update( + db, + db_user, + email=confirm_data.new_email + ) + + # 清除用户信息缓存 + cache_key = redis_key_manager.get_user_info_key(user_id) + await cache_service.delete(cache_key) + + return schemas.UserMe.model_validate(updated_orm) + except SQLAlchemyError: + raise exceptions.DatabaseOperationFailedException("confirm and update email") + +# 新增添加这个 Service 方法 +async def get_login_history_service( + db: AsyncSession, + user_id: int, + page: int, + page_size: int +) -> schemas.PaginatedLoginHistory: + """ + 获取登录历史分页数据。 + + Args: + db (AsyncSession): 数据库会话。 + user_id (int): 用户 ID。 + page (int): 页码。 + page_size (int): 每页数量。 + + Returns: + schemas.PaginatedLoginHistory: 分页结果。 + """ + # 1. 计算 offset + skip = (page - 1) * page_size + + # 2. 调用 CRUD 获取数据 + items_orm, total = await crud_login_history.get_multi_by_user( + db, user_id, skip=skip, limit=page_size + ) + + # 3. 转换为 DTO + # 注意:LoginHistoryItem 需要在 schema/user.py 中正确定义 (你之前的上传中已经有了) + items_dto = [schemas.LoginHistoryItem.model_validate(item) for item in items_orm] + + return schemas.PaginatedLoginHistory( + total=total, + page=page, + page_size=page_size, + items=items_dto + ) diff --git a/src/backend/app/service/vector_db_service.py b/src/backend/app/service/vector_db_service.py new file mode 100644 index 0000000..f255ea8 --- /dev/null +++ b/src/backend/app/service/vector_db_service.py @@ -0,0 +1,544 @@ +""" +向量数据库服务模块。 + +使用 ChromaDB 作为向量数据库,支持: +- 历史对话的向量存储与检索 +- 领域知识的向量存储与检索 +- Schema/DDL 片段的向量存储与检索 + +ChromaDB 部署模式: +- 内嵌模式 (Embedded): 本地开发,无需额外服务 +- HTTP 客户端模式: 连接 Docker 中的 ChromaDB 服务(生产环境) + +使用方式: + from service.vector_db_service import vector_db_service + + # 添加文档 + await vector_db_service.add_documents( + collection_name="knowledge", + documents=["文档1", "文档2"], + metadatas=[{"id": 1}, {"id": 2}], + ids=["doc1", "doc2"] + ) + + # 相似度搜索 + results = await vector_db_service.search( + collection_name="knowledge", + query_text="查询文本", + top_k=5 + ) +""" + +import os +import asyncio +from typing import List, Dict, Any, Optional +from pathlib import Path +import chromadb +from chromadb.config import Settings + +from core.log import log +from core.config import settings + + +class VectorDBService: + """ + 向量数据库服务类。 + + 封装 ChromaDB 操作,提供异步接口。 + 支持多个 Collection(集合)用于不同类型的数据。 + + 部署模式: + - use_http_client=False: 内嵌模式,数据存储在本地 + - use_http_client=True: HTTP 客户端模式,连接 Docker 中的 ChromaDB + """ + + # 预定义的 Collection 名称 + COLLECTION_HISTORY = "chat_history" # 历史对话 + COLLECTION_KNOWLEDGE = "domain_knowledge" # 领域知识 + COLLECTION_DDL = "table_ddl" # 表 DDL(CREATE TABLE 语句) + # 保留旧名称作为别名,兼容旧代码 + COLLECTION_SCHEMA = COLLECTION_DDL + + def __init__( + self, + host: Optional[str] = None, + port: Optional[int] = None, + use_http_client: Optional[bool] = None, + persist_directory: Optional[str] = None + ): + """ + 初始化向量数据库服务。 + + Args: + host: ChromaDB 服务主机(HTTP 模式) + port: ChromaDB 服务端口(HTTP 模式) + use_http_client: 是否使用 HTTP 客户端模式 + persist_directory: 持久化目录路径(内嵌模式) + """ + # 从配置读取默认值 + chroma_config = getattr(settings, 'chroma', None) + + self.host = host or (chroma_config.host if chroma_config else "localhost") + self.port = port or (chroma_config.port if chroma_config else 8100) + self.use_http_client = use_http_client if use_http_client is not None else ( + chroma_config.use_http_client if chroma_config else False + ) + + # 内嵌模式的持久化目录 + if persist_directory: + self.persist_directory = persist_directory + elif chroma_config: + self.persist_directory = chroma_config.persist_directory + else: + self.persist_directory = "data/chroma_db" + + # 确保内嵌模式目录存在 + if not self.use_http_client: + Path(self.persist_directory).mkdir(parents=True, exist_ok=True) + + self._client = None + self._collections: Dict[str, Any] = {} + + log.info(f"VectorDB mode: {'HTTP Client' if self.use_http_client else 'Embedded'}") + + def _get_client(self) -> chromadb.ClientAPI: + """获取或创建 ChromaDB 客户端(懒加载)。""" + if self._client is None: + if self.use_http_client: + # HTTP 客户端模式:连接 Docker 中的 ChromaDB + self._client = chromadb.HttpClient( + host=self.host, + port=self.port, + settings=Settings( + anonymized_telemetry=False + ) + ) + log.info(f"ChromaDB HTTP client connected to {self.host}:{self.port}") + else: + # 内嵌模式:本地持久化 + self._client = chromadb.PersistentClient( + path=self.persist_directory, + settings=Settings( + anonymized_telemetry=False, + allow_reset=True + ) + ) + log.info(f"ChromaDB embedded client initialized at {self.persist_directory}") + return self._client + + def _get_collection(self, collection_name: str) -> Any: + """ + 获取或创建 Collection。 + + Args: + collection_name: 集合名称 + + Returns: + ChromaDB Collection 对象 + """ + if collection_name not in self._collections: + client = self._get_client() + self._collections[collection_name] = client.get_or_create_collection( + name=collection_name, + metadata={"hnsw:space": "cosine"} # 使用余弦相似度 + ) + log.info(f"Collection '{collection_name}' loaded/created") + return self._collections[collection_name] + + async def add_documents( + self, + collection_name: str, + documents: List[str], + embeddings: List[List[float]], + metadatas: Optional[List[Dict[str, Any]]] = None, + ids: Optional[List[str]] = None + ) -> None: + """ + 添加文档到向量数据库。 + + Args: + collection_name: 集合名称 + documents: 文档文本列表 + embeddings: 对应的向量列表 + metadatas: 元数据列表(可选) + ids: 文档 ID 列表(可选,自动生成) + """ + if not documents: + return + + # 生成默认 ID + if ids is None: + import uuid + ids = [str(uuid.uuid4()) for _ in documents] + + # 默认元数据 + if metadatas is None: + metadatas = [{} for _ in documents] + + collection = self._get_collection(collection_name) + + # ChromaDB 操作是同步的,使用线程池执行 + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: collection.add( + documents=documents, + embeddings=embeddings, + metadatas=metadatas, + ids=ids + ) + ) + log.debug(f"Added {len(documents)} documents to '{collection_name}'") + + async def upsert_documents( + self, + collection_name: str, + documents: List[str], + embeddings: List[List[float]], + metadatas: Optional[List[Dict[str, Any]]] = None, + ids: List[str] = None + ) -> None: + """ + 更新或插入文档(根据 ID)。 + + Args: + collection_name: 集合名称 + documents: 文档文本列表 + embeddings: 对应的向量列表 + metadatas: 元数据列表 + ids: 文档 ID 列表(必需) + """ + if not documents or not ids: + return + + if metadatas is None: + metadatas = [{} for _ in documents] + + collection = self._get_collection(collection_name) + + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: collection.upsert( + documents=documents, + embeddings=embeddings, + metadatas=metadatas, + ids=ids + ) + ) + log.debug(f"Upserted {len(documents)} documents in '{collection_name}'") + + async def search( + self, + collection_name: str, + query_embedding: List[float], + top_k: int = 5, + where: Optional[Dict[str, Any]] = None, + include: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + 相似度搜索。 + + Args: + collection_name: 集合名称 + query_embedding: 查询向量 + top_k: 返回结果数量 + where: 过滤条件(如 {"project_id": 123}) + include: 返回字段(默认 ["documents", "metadatas", "distances"]) + + Returns: + Dict: 搜索结果,包含 documents, metadatas, distances + """ + if include is None: + include = ["documents", "metadatas", "distances"] + + collection = self._get_collection(collection_name) + + loop = asyncio.get_event_loop() + results = await loop.run_in_executor( + None, + lambda: collection.query( + query_embeddings=[query_embedding], + n_results=top_k, + where=where, + include=include + ) + ) + + # 解包结果(query 返回的是嵌套列表) + return { + "ids": results["ids"][0] if results["ids"] else [], + "documents": results["documents"][0] if results.get("documents") else [], + "metadatas": results["metadatas"][0] if results.get("metadatas") else [], + "distances": results["distances"][0] if results.get("distances") else [] + } + + async def delete_by_ids( + self, + collection_name: str, + ids: List[str] + ) -> None: + """ + 根据 ID 删除文档。 + + Args: + collection_name: 集合名称 + ids: 要删除的文档 ID 列表 + """ + if not ids: + return + + collection = self._get_collection(collection_name) + + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: collection.delete(ids=ids) + ) + log.debug(f"Deleted {len(ids)} documents from '{collection_name}'") + + async def delete_by_filter( + self, + collection_name: str, + where: Dict[str, Any] + ) -> None: + """ + 根据过滤条件删除文档。 + + Args: + collection_name: 集合名称 + where: 过滤条件 + """ + collection = self._get_collection(collection_name) + + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: collection.delete(where=where) + ) + log.debug(f"Deleted documents from '{collection_name}' with filter: {where}") + + async def get_collection_count(self, collection_name: str) -> int: + """获取集合中的文档数量。""" + collection = self._get_collection(collection_name) + + loop = asyncio.get_event_loop() + count = await loop.run_in_executor( + None, + lambda: collection.count() + ) + return count + + async def clear_collection(self, collection_name: str) -> None: + """清空集合中的所有文档。""" + client = self._get_client() + + loop = asyncio.get_event_loop() + # 删除并重新创建集合 + await loop.run_in_executor( + None, + lambda: client.delete_collection(collection_name) + ) + + # 从缓存中移除 + if collection_name in self._collections: + del self._collections[collection_name] + + log.info(f"Collection '{collection_name}' cleared") + + +# 全局单例实例 +vector_db_service = VectorDBService() + + +# ========================================================= +# 特定用途的辅助函数 +# ========================================================= + +async def add_chat_history( + session_id: int, + message_id: int, + content: str, + embedding: List[float], + role: str = "user" +) -> None: + """ + 添加聊天历史到向量数据库。 + + Args: + session_id: 会话 ID + message_id: 消息 ID + content: 消息内容 + embedding: 消息向量 + role: 角色(user/assistant) + """ + await vector_db_service.add_documents( + collection_name=VectorDBService.COLLECTION_HISTORY, + documents=[content], + embeddings=[embedding], + metadatas=[{ + "session_id": session_id, + "message_id": message_id, + "role": role + }], + ids=[f"msg_{message_id}"] + ) + + +async def add_domain_knowledge( + project_id: int, + knowledge_id: int, + term: str, + definition: str, + embedding: List[float] +) -> None: + """ + 添加领域知识到向量数据库。 + + Args: + project_id: 项目 ID + knowledge_id: 知识 ID + term: 术语 + definition: 定义 + embedding: 向量(通常是 term + definition 的组合) + """ + await vector_db_service.upsert_documents( + collection_name=VectorDBService.COLLECTION_KNOWLEDGE, + documents=[f"{term}: {definition}"], + embeddings=[embedding], + metadatas=[{ + "project_id": project_id, + "knowledge_id": knowledge_id, + "term": term + }], + ids=[f"knowledge_{knowledge_id}"] + ) + + +async def add_schema_fragment( + project_id: int, + table_name: str, + ddl_fragment: str, + embedding: List[float] +) -> None: + """ + 添加 Schema/DDL 片段到向量数据库。 + + Args: + project_id: 项目 ID + table_name: 表名 + ddl_fragment: DDL 片段(CREATE TABLE 语句) + embedding: 向量 + """ + await vector_db_service.upsert_documents( + collection_name=VectorDBService.COLLECTION_DDL, + documents=[ddl_fragment], + embeddings=[embedding], + metadatas=[{ + "project_id": project_id, + "table_name": table_name + }], + ids=[f"ddl_{project_id}_{table_name}"] + ) + + +async def search_similar_history( + query_embedding: List[float], + session_id: int, + top_k: int = 5 +) -> List[Dict[str, Any]]: + """ + 搜索相似的历史对话。 + + Args: + query_embedding: 查询向量 + session_id: 会话 ID(限定范围) + top_k: 返回数量 + + Returns: + List[Dict]: 相似的历史消息列表 + """ + results = await vector_db_service.search( + collection_name=VectorDBService.COLLECTION_HISTORY, + query_embedding=query_embedding, + top_k=top_k, + where={"session_id": session_id} + ) + + return [ + { + "id": results["ids"][i], + "content": results["documents"][i], + "metadata": results["metadatas"][i], + "distance": results["distances"][i] + } + for i in range(len(results["ids"])) + ] + + +async def search_relevant_knowledge( + query_embedding: List[float], + project_id: int, + top_k: int = 5 +) -> List[Dict[str, Any]]: + """ + 搜索相关的领域知识。 + + Args: + query_embedding: 查询向量 + project_id: 项目 ID + top_k: 返回数量 + + Returns: + List[Dict]: 相关的领域知识列表 + """ + results = await vector_db_service.search( + collection_name=VectorDBService.COLLECTION_KNOWLEDGE, + query_embedding=query_embedding, + top_k=top_k, + where={"project_id": project_id} + ) + + return [ + { + "id": results["ids"][i], + "content": results["documents"][i], + "metadata": results["metadatas"][i], + "distance": results["distances"][i] + } + for i in range(len(results["ids"])) + ] + + +async def search_relevant_schema( + query_embedding: List[float], + project_id: int, + top_k: int = 5 +) -> List[Dict[str, Any]]: + """ + 搜索相关的 DDL 片段(CREATE TABLE 语句)。 + + Args: + query_embedding: 查询向量 + project_id: 项目 ID + top_k: 返回数量 + + Returns: + List[Dict]: 相关的 DDL 片段列表 + """ + results = await vector_db_service.search( + collection_name=VectorDBService.COLLECTION_DDL, + query_embedding=query_embedding, + top_k=top_k, + where={"project_id": project_id} + ) + + return [ + { + "id": results["ids"][i], + "content": results["documents"][i], + "metadata": results["metadatas"][i], + "distance": results["distances"][i] + } + for i in range(len(results["ids"])) + ] diff --git a/src/backend/app/sqlite/__init__.py b/src/backend/app/sqlite/__init__.py new file mode 100644 index 0000000..5120665 --- /dev/null +++ b/src/backend/app/sqlite/__init__.py @@ -0,0 +1,7 @@ +""" +SQLite 模块初始化。 + +提供 SQLite 数据库连接、执行、转换和安全校验等核心功能。 +""" + +# backend/app/sqlite/__init__.py \ No newline at end of file diff --git a/src/backend/app/sqlite/sqlite_converter.py b/src/backend/app/sqlite/sqlite_converter.py new file mode 100644 index 0000000..6e05721 --- /dev/null +++ b/src/backend/app/sqlite/sqlite_converter.py @@ -0,0 +1,529 @@ +""" +SQLite 转换器。 + +专门用于将其他数据库模式和 SQL 语句深度转换为 SQLite 兼容格式。 +""" + +# backend/app/sqlite/sqlite_converter.py + +from typing import Dict, Any, List +from core.sql_dialect_converter import SQLDialectConverter, SQLConversionError + +import re +import sqlglot +from sqlglot import exp, parse_one +from typing import Dict, List, Optional, Tuple, Union, Callable +from collections import defaultdict + +# 动态导入表达式类型 (解决版本兼容问题) +try: + # sqlglot 24.0+ 使用新结构 + from sqlglot.expressions import ( + CreateTable as CreateTableExpr, + ColumnDef as ColumnDefExpr, + ForeignKey as ForeignKeyExpr, + PrimaryKey as PrimaryKeyExpr, + DataType as DataTypeExpr, + Properties as PropertiesExpr, + Property as PropertyExpr, + Literal as LiteralExpr, + Var as VarExpr, + OnDelete as OnDeleteExpr, + OnUpdate as OnUpdateExpr + ) +except ImportError: + # 旧版本回退 + CreateTableExpr = type('CreateTable', (), {'key': 'create_table'}) + ColumnDefExpr = type('ColumnDef', (), {'key': 'column_def'}) + ForeignKeyExpr = type('ForeignKey', (), {'key': 'foreign_key'}) + PrimaryKeyExpr = type('PrimaryKey', (), {'key': 'primary_key'}) + DataTypeExpr = exp.DataType + PropertiesExpr = exp.Properties + PropertyExpr = exp.Property + LiteralExpr = exp.Literal + VarExpr = exp.Var + OnDeleteExpr = exp.Delete + OnUpdateExpr = exp.Update + +class SQLiteConverter: + """ + 通用数据库到SQLite深度转换器 (完全兼容 sqlglot 24.0+) + 专为模式转换优化,精准处理保留字/主键/精度/外键等关键问题 + """ + + # SQLite保留字集合 (来自SQLite官方文档) + SQLITE_RESERVED_WORDS = { + "user", "order", "group", "key", "index", "system", "option", "status", + "current_date", "current_time", "current_timestamp", "database", "schema", + "table", "view", "procedure", "function", "trigger", "event", "partition", + "session", "transaction", "commit", "rollback", "savepoint", "lock", "grant", + "revoke", "role", "type", "enum", "array", "json", "jsonb", "xml", "uuid", + "abs", "changes", "char", "coalesce", "glob", "hex", "ifnull", "instr", + "last_insert_rowid", "length", "like", "lower", "ltrim", "max", "min", + "nullif", "printf", "quote", "replace", "round", "rtrim", "soundex", + "typeof", "upper", "sqlite_version" + } + + # 智能字段映射规则 (列名 -> 精度规则) + FIELD_PRECISION_RULES = { + # 价格/金额字段 + r"(?i)(price|amount|cost|fee|total|balance|discount)": ("DECIMAL", (10, 2)), + # 数量/计数字段 + r"(?i)(quantity|count|num|stock|capacity)": ("INTEGER", None), + # 折扣率/百分比字段 + r"(?i)(discount|rate|percentage|ratio|probability)": ("REAL", (5, 4)), + # 时长/时间字段 + r"(?i)(duration|time_span|hours|minutes|seconds)": ("REAL", (10, 2)), + # ID/标识符字段 + r"(?i)(id|uuid|guid|code|identifier|ref)": ("TEXT", 50), + # 电话/信用卡等特殊字段 + r"(?i)(phone|telephone|contact_number)": ("TEXT", 20), + r"(?i)(credit_card|card_number)": ("TEXT", 19), + # 通用文本字段 (主键/外键相关) + r"(?i)(name|type|category|status|preference)": ("TEXT", 100), + } + + # 默认TEXT长度 (主键/索引列) - SQLite使用TEXT类型,没有长度限制 + DEFAULT_TEXT_LENGTH = 255 + + def __init__(self): + """ + 初始化转换器。 + + 设置基础 SQL 方言转换器、启用深度转换并初始化统计计数器。 + 同时建立 AST 节点类型与转换方法的映射关系。 + """ + self.generic_converter = SQLDialectConverter() + self.deep_conversion_enabled = True + self.conversion_stats = defaultdict(int) + + # 重构转换器映射 (使用节点key而非类类型) + self.ast_transformers = { + "create_table": self._transform_create_table, + "foreign_key": self._transform_foreign_key, + "primary_key": self._transform_primary_key, + "column_def": self._transform_column_def, + } + + def _apply_ast_transformations(self, ast: exp.Expression): + """ + 递归应用 AST 转换规则 (兼容 sqlglot 新旧版本)。 + + 遍历抽象语法树 (AST) 的每个节点,根据节点类型 (如 create_table, foreign_key) + 调用相应的转换方法 (`_transform_xxx`) 进行深度修改。 + + Args: + ast (exp.Expression): 待转换的 SQL 抽象语法树根节点。 + """ + # 遍历所有节点 + for node in ast.walk(): + # 获取节点类型标识 (兼容新旧版本) + node_key = getattr(node, "key", None) + if not node_key and hasattr(node, "__class__"): + node_key = node.__class__.__name__.lower() + + # 应用匹配的转换器 + transformer = self.ast_transformers.get(node_key) + if transformer: + try: + transformer(node) + except Exception as e: + print(f"转换节点 {node_key} 时出错: {str(e)}") + + # 全局表属性修复 (必须最后执行) + for node in ast.walk(): + node_key = getattr(node, "key", None) or node.__class__.__name__.lower() + if node_key == "create_table": + self._add_table_properties(node) + + def _transform_create_table(self, node): + """ + 转换 CREATE TABLE 语句。 + + 主要功能: + 1. 检查表名是否为 SQLite 保留字(如 user, group, order 等)。 + 2. 如果是保留字,则使用方括号 ([ ]) 包裹表名,避免语法错误。 + + Args: + node: create_table 类型的 AST 节点。 + """ + # 获取表名 (兼容新旧结构) + table_name = None + if hasattr(node, "this") and hasattr(node.this, "name"): + table_name = node.this.name.lower() + elif hasattr(node, "expression") and hasattr(node.expression, "name"): + table_name = node.expression.name.lower() + + if table_name and table_name in self.SQLITE_RESERVED_WORDS: + # 用方括号包裹表名 + new_name = f"[{node.this.name}]" if hasattr(node, "this") else f"[{node.expression.name}]" + if hasattr(node, "this"): + node.this.set("this", new_name) + else: + node.expression.set("this", new_name) + self.conversion_stats["reserved_word_fixes"] += 1 + + def _transform_primary_key(self, node): + """ + 修复主键定义。 + + SQLite 允许主键为 TEXT 类型,但需要确保主键约束的正确性。 + 此方法会处理主键定义的格式。 + + Args: + node: primary_key 类型的 AST 节点。 + """ + # 获取主键列表达式 + pk_columns = [] + if hasattr(node, "expressions"): + pk_columns = node.expressions + elif hasattr(node, "args") and "expressions" in node.args: + pk_columns = node.args["expressions"] + + # 检查主键列 + for col_expr in pk_columns: + col_name = None + if hasattr(col_expr, "name"): + col_name = col_expr.name + elif hasattr(col_expr, "this") and hasattr(col_expr.this, "name"): + col_name = col_expr.this.name + + if not col_name: + continue + + # 向上查找表定义 + table_node = node.find_ancestor(lambda n: getattr(n, "key", "") == "create_table") + if not table_node: + continue + + # 查找列定义 + for col_def in table_node.find_all(lambda n: getattr(n, "key", "") == "column_def"): + if hasattr(col_def, "this") and col_def.this.name == col_name: + # 检查类型 + col_type = None + if hasattr(col_def, "kind"): + col_type = col_def.kind + elif hasattr(col_def, "args") and "kind" in col_def.args: + col_type = col_def.args["kind"] + + if col_type and hasattr(col_type, "this") and col_type.this == "TEXT": + # SQLite TEXT类型主键不需要特殊处理 + self.conversion_stats["text_primary_key_fixes"] += 1 + + def _transform_column_def(self, node): + """ + 智能修复列定义。 + + 包含两个主要修复逻辑: + 1. **TEXT 主键修复**:如果列定义中包含主键约束且类型为 TEXT,转为 TEXT。 + 2. **智能精度分配**:针对 DECIMAL/NUMERIC 类型,根据列名模式(如 price, rate)自动匹配 + 合适的精度(如 REAL),避免 SQLite 默认精度可能导致的精度丢失问题。 + + Args: + node: column_def 类型的 AST 节点。 + """ + col_name = node.this.name.lower() if hasattr(node, "this") else "" + + # 1. 修复TEXT主键 (列级主键) + if hasattr(node, "constraints"): + has_primary_key_constraint = False + for constraint in node.constraints: + if getattr(constraint, "kind", None) == "primary": + has_primary_key_constraint = True + col_type = getattr(node, "kind", None) + if col_type and getattr(col_type, "this", None) == "TEXT": + # SQLite TEXT类型主键不需要特殊处理 + self.conversion_stats["text_primary_key_fixes"] += 1 + break # 修复一次就够了 + + # 如果有主键约束但类型不是TEXT,则不需要额外处理 + if has_primary_key_constraint: + return + + # 2. 智能精度分配 + col_type = getattr(node, "kind", None) + if col_type and getattr(col_type, "this", None) in ("NUMERIC", "DECIMAL"): + for pattern, (data_type, precision) in self.FIELD_PRECISION_RULES.items(): + if re.search(pattern, col_name): + # 应用精度规则 + if data_type == "INTEGER": + new_type = DataTypeExpr.Type.INT + elif data_type == "REAL" and precision: + new_type = DataTypeExpr.Type.FLOAT + elif data_type in ("TEXT",) and precision: + new_type = DataTypeExpr.Type.TEXT + else: + new_type = DataTypeExpr.Type.FLOAT + + node.set("kind", new_type) + self.conversion_stats[f"precision_fix_{col_name}"] += 1 + break + else: + # 默认精度规则 - SQLite使用REAL类型 + node.set("kind", DataTypeExpr.Type.FLOAT) + self.conversion_stats["default_precision_fixes"] += 1 + + def _transform_foreign_key(self, node): + """ + 添加缺失的外键级联规则。 + + SQLite 往往省略外键行为,但需要显式指定。 + 如果外键定义中缺少 ON DELETE 或 ON UPDATE 规则,此方法会默认添加 CASCADE 级联规则, + 确保数据完整性。 + + Args: + node: foreign_key 类型的 AST 节点。 + """ + # 检查是否已有ON DELETE/UPDATE + has_on_delete = False + has_on_update = False + + if hasattr(node, "expressions"): + for expr in node.expressions: + if isinstance(expr, OnDeleteExpr): + has_on_delete = True + elif isinstance(expr, OnUpdateExpr): + has_on_update = True + + # 添加缺失的级联规则 + if not has_on_delete: + on_delete = OnDeleteExpr(this=VarExpr(this="CASCADE")) + if hasattr(node, "append"): + node.append("expressions", on_delete) + else: + node.expressions.append(on_delete) + self.conversion_stats["fk_cascade_additions"] += 1 + + if not has_on_update: + on_update = OnUpdateExpr(this=VarExpr(this="CASCADE")) + if hasattr(node, "append"): + node.append("expressions", on_update) + else: + node.expressions.append(on_update) + self.conversion_stats["fk_cascade_additions"] += 1 + + def _add_table_properties(self, node): + """ + 添加表属性。 + + SQLite 表属性相对简单,主要确保表名正确处理。 + 这是建表语句转换的最后一步。 + + Args: + node: create_table 类型的 AST 节点。 + """ + # SQLite 通常不需要强制指定引擎,但可以添加表空间等属性 + # 这里主要确保表名正确处理(保留字处理) + + # 确保表名正确处理 (方括号) + if hasattr(node, "this") and hasattr(node.this, "name"): + table_name = node.this.name + if table_name.lower() in self.SQLITE_RESERVED_WORDS: + node.this.set("this", f"[{table_name}]") + + self.conversion_stats["table_properties_added"] += 1 + + def convert_schema(self, source_sql: str) -> str: + """ + 深度转换源数据库表结构定义到 SQLite 格式。 + + 结合了通用方言转换(基于 sqlglot 默认规则)和自定义 AST 深度转换(`_apply_ast_transformations`), + 生成生产级可用的 SQLite DDL 语句。 + + Args: + source_sql (str): 源数据库格式的 DDL 语句。 + + Returns: + str: 转换后的 SQLite 格式 DDL 语句。 + """ + try: + # 基础转换 + sqlite_sql = self.generic_converter.convert( + source_sql, 'postgres', 'sqlite', pretty=True # 默认从PostgreSQL转换 + ) + + # AST深度转换 (仅当启用时) + if self.deep_conversion_enabled: + try: + # 关键修复: 使用正确的read方言 + ast = parse_one(sqlite_sql, read="sqlite", dialect="sqlite") + self._apply_ast_transformations(ast) + sqlite_sql = ast.sql(dialect="sqlite", pretty=True) + except Exception as ast_err: + self.conversion_stats["ast_conversion_failures"] += 1 + print(f"AST转换失败,回退到基础转换: {str(ast_err)}") + + # 兜底修复 + sqlite_sql = self._apply_fallback_fixes(sqlite_sql) + return sqlite_sql + except Exception as e: + raise SQLConversionError(f"源数据库到SQLite模式转换失败: {str(e)}") + + def _apply_fallback_fixes(self, sql: str) -> str: + """字符串级兜底修复 (AST转换失败时使用)""" + # 1. 修复TEXT主键 (简单模式) + # 移除重复的PRIMARY KEY定义,只保留列级别的 + sql = re.sub( + r'(^\s*CREATE\s+TABLE\s+\w+\s*$$[^$$]*?),\s*PRIMARY\s+KEY\s*$$[^$$]*?$$', + r'\1', + sql, + flags=re.IGNORECASE | re.MULTILINE | re.DOTALL + ) + + # 将TEXT类型主键转换为TEXT并保留PRIMARY KEY约束 + sql = re.sub( + r'(\w+)\s+TEXT\s+NOT NULL', + lambda m: f'{m.group(1)} TEXT NOT NULL', + sql, + flags=re.IGNORECASE + ) + + # 2. 修复NUMERIC精度 (通用规则) + sql = re.sub( + r'(\w+)\s+NUMERIC\s*', + r'\1 REAL', + sql, + flags=re.IGNORECASE + ) + + # 3. 修复SERIAL为INTEGER PRIMARY KEY AUTOINCREMENT + sql = re.sub( + r'(\w+)\s+SERIAL', + r'\1 INTEGER PRIMARY KEY AUTOINCREMENT', + sql, + flags=re.IGNORECASE + ) + + # 4. 修复布尔值 + sql = re.sub( + r'(\w+)\s+BOOLEAN\s*', + r'\1 INTEGER', # SQLite中布尔值用INTEGER表示 + sql, + flags=re.IGNORECASE + ) + + return sql + + def convert_statement(self, source_sql: str) -> str: + """ + 转换源数据库SQL语句到SQLite格式 + + Args: + source_sql: 源数据库的SQL语句 + + Returns: + SQLite兼容的SQL语句 + """ + try: + # 基础转换 + sqlite_sql = self.generic_converter.convert( + source_sql, 'postgres', 'sqlite', pretty=False # 默认从PostgreSQL转换 + ) + + # 仅对DDL语句进行深度转换 + if any(keyword in sqlite_sql.upper() for keyword in ["CREATE TABLE", "ALTER TABLE"]): + return self.convert_schema(sqlite_sql) # 复用schema转换 + + # DML/其他语句 - 应用轻量级修复 + return self._apply_statement_fixes(sqlite_sql) + except Exception as e: + raise SQLConversionError(f"源数据库到SQLite语句转换失败: {str(e)}") + + def _apply_statement_fixes(self, sql: str) -> str: + """轻量级语句修复 (DML/其他语句)""" + # 1. 修复布尔值 + sql = sql.replace('TRUE', '1').replace('FALSE', '0') # SQLite中布尔值用1/0 + + # 2. 修复日期函数 + sql = re.sub( + r"TO_CHAR\(([^,]+),\s*'YYYY-MM-DD HH24:MI:SS'\)", + r"strftime('%Y-%m-%d %H:%M:%S', \1)", + sql, + flags=re.IGNORECASE + ) + + # 3. 修复SERIAL + sql = sql.replace('SERIAL', 'INTEGER PRIMARY KEY AUTOINCREMENT') + + # 4. 修复LIMIT语法 + sql = re.sub( + r'LIMIT\s+(\d+)\s*OFFSET\s*(\d+)', + r'LIMIT \2, \1', # SQLite语法 + sql, + flags=re.IGNORECASE + ) + + return sql + + def batch_convert(self, source_statements: List[str]) -> List[str]: + """ + 批量转换源数据库语句到SQLite格式 (带错误隔离) + + Args: + source_statements: 源数据库语句列表 + + Returns: + 转换结果列表 (失败语句包含错误注释) + """ + sqlite_statements = [] + for i, statement in enumerate(source_statements): + stmt = statement.strip() + if not stmt: + continue + + try: + # 智能判断语句类型 + if "CREATE TABLE" in stmt.upper() or "ALTER TABLE" in stmt.upper(): + converted = self.convert_schema(stmt) + else: + converted = self.convert_statement(stmt) + + sqlite_statements.append(converted) + self.conversion_stats["successful_conversions"] += 1 + except SQLConversionError as e: + self.conversion_stats["conversion_errors"] += 1 + error_annotation = f"-- 转换失败 (语句#{i + 1}): {str(e)}\n" + sqlite_statements.append(f"{error_annotation}{stmt}") + except Exception as e: + self.conversion_stats["unexpected_errors"] += 1 + error_annotation = f"-- 未处理错误 (语句#{i + 1}): {str(e)}\n" + sqlite_statements.append(f"{error_annotation}{stmt}") + + return sqlite_statements + + def get_conversion_stats(self) -> Dict[str, int]: + """获取转换统计信息""" + return dict(self.conversion_stats) + + def disable_deep_conversion(self): + """禁用深度AST转换 (回退到基础模式)""" + self.deep_conversion_enabled = False + + def enable_deep_conversion(self): + """启用深度AST转换""" + self.deep_conversion_enabled = True + + +# 使用示例 +if __name__ == "__main__": + # 示例:PostgreSQL到SQLite的深度转换 + converter = SQLiteConverter() + + # PostgreSQL表结构示例 + postgres_create_table = """CREATE TABLE "Hotel" ("Name" TEXT NOT NULL, "Address" TEXT, "Phone" TEXT, PRIMARY KEY("Name")); +CREATE TABLE "Room" ("Type" TEXT NOT NULL, "Price" NUMERIC, "Quantity" NUMERIC, PRIMARY KEY("Type")); +CREATE TABLE "Booking" ("BookingID" TEXT NOT NULL, "RoomType" TEXT NOT NULL, "StartDate" TIMESTAMP, "EndDate" TIMESTAMP, PRIMARY KEY("BookingID"), FOREIGN KEY("RoomType") REFERENCES "Room"("Type")); +CREATE TABLE "User" ("UserID" TEXT NOT NULL, "Name" TEXT, "Contact" TEXT, "Discount" NUMERIC, "CreditCard" TEXT, PRIMARY KEY("UserID")); +CREATE TABLE "Booking_Room" ("BookingID" TEXT NOT NULL, "RoomType" TEXT NOT NULL, "Duration" NUMERIC, "RoomPreference" TEXT, PRIMARY KEY("BookingID", "RoomType"), FOREIGN KEY("BookingID") REFERENCES "Booking"("BookingID"), FOREIGN KEY("RoomType") REFERENCES "Room"("Type")); +CREATE TABLE "User_Booking" ("UserID" TEXT NOT NULL, "BookingID" TEXT NOT NULL, "PaymentMethod" TEXT, "LoyaltyProgram" TEXT, PRIMARY KEY("UserID", "BookingID"), FOREIGN KEY("UserID") REFERENCES "User"("UserID"), FOREIGN KEY("BookingID") REFERENCES "Booking"("BookingID"));""" + + for sql in postgres_create_table.split(';'): + if sql.strip(): + try: + sqlite_create_table = converter.convert_schema(sql) + print("PostgreSQL:") + print(sql) + print("\n转换后的SQLite:") + print(sqlite_create_table) + except SQLConversionError as e: + print(f"转换失败: {e}") \ No newline at end of file diff --git a/src/backend/app/sqlite/sqlite_database.py b/src/backend/app/sqlite/sqlite_database.py new file mode 100644 index 0000000..0097600 --- /dev/null +++ b/src/backend/app/sqlite/sqlite_database.py @@ -0,0 +1,212 @@ +from typing import Optional, List +import os +from pathlib import Path +""" +SQLite 数据库管理器。 + +处理 SQLite 数据库连接、引擎初始化及资源释放,支持用户连接池管理。 +SQLite是文件型数据库,每个数据库对应一个文件,通过文件路径进行管理。 +""" + +# backend/app/sqlite/sqlite_database.py + +from typing import Optional +import os +from pathlib import Path + +from sqlalchemy import create_engine, text +from sqlalchemy.pool import StaticPool +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from config.base import SQLiteConfig +from core.exceptions import InvalidOperationException +from core.log import log +from models import DatabaseInstance + + +class SQLiteHelper: + """ + SQLite 数据库连接处理类 (原生 SQL 执行模式)。 + + 设计原则: + 1. 业务 SQL 通过普通用户连接执行。 + 2. 严格隔离权限,防止 SQL 注入。 + 3. 为每个数据库实例创建独立的连接池。 + """ + + _user_engine: dict[int, AsyncEngine] = {} + _user_sync_engine: dict[int, object] = {} # 同步引擎用于DDL操作 + + @classmethod + async def test_connection(cls, database_instance: DatabaseInstance): + """ + 测试数据库连接是否可用。 + + 执行简单的 `SELECT 1` 语句来验证数据库连通性。 + + Args: + database_instance (DatabaseInstance): 数据库实例对象,包含数据库名称等信息。 + """ + engine = await cls.get_user_engine(database_instance) + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + + @classmethod + async def init_user_engine(cls, sqlite_config: SQLiteConfig, instance_id: int, db_name: str, user_id: Optional[int] = None): + """ + 初始化普通用户引擎。 + + 为每个具体的数据库实例(Project)建立独立的连接池,使用文件路径连接。 + 支持用户隔离,不同用户的数据存储在不同的目录中。 + + Args: + sqlite_config (SQLiteConfig): SQLite配置对象。 + instance_id (int): 数据库实例 ID,作为连接池的 Key。 + db_name (str): 数据库名称(将作为文件名)。 + user_id (Optional[int]): 用户ID,用于隔离不同用户的数据库文件。 + """ + # 构建数据库文件路径(支持用户隔离) + db_file_path = sqlite_config.get_database_path(db_name, user_id) + db_url = f"sqlite:///{db_file_path}" + + # SQLite连接参数 - 使用同步引擎创建数据库文件 + sync_engine = create_engine( + db_url, + connect_args={ + "check_same_thread": False # 允许多线程访问 + }, + poolclass=StaticPool, # 使用静态池避免连接问题 + echo=sqlite_config.echo + ) + + # 确保数据库文件存在 + with sync_engine.connect() as conn: + conn.execute(text("SELECT 1")) # 简单查询以确保文件创建 + + cls._user_sync_engine[instance_id] = sync_engine + + # 创建异步引擎 + async_db_url = f"sqlite+aiosqlite:///{db_file_path}" + connect_args = { + "check_same_thread": False # 允许多线程访问 + } + + log.info(f"init user engine: {async_db_url}") + # SQLite不需要连接池参数 + cls._user_engine[instance_id] = create_async_engine( + async_db_url, + connect_args=connect_args, + echo=sqlite_config.echo + ) + + @classmethod + async def close_user_engine(cls, database_instance: DatabaseInstance): + """ + 关闭指定的用户引擎。 + + 释放特定数据库实例的连接池资源。 + + Args: + database_instance (DatabaseInstance): 需要关闭的数据库实例对象。 + """ + if database_instance.instance_id in cls._user_engine: + await cls._user_engine[database_instance.instance_id].dispose() + del cls._user_engine[database_instance.instance_id] + + if database_instance.instance_id in cls._user_sync_engine: + cls._user_sync_engine[database_instance.instance_id].dispose() + del cls._user_sync_engine[database_instance.instance_id] + + log.info(f"close user engine: {database_instance.instance_id}") + + @classmethod + async def close_all_engine(cls): + """ + 关闭所有引擎 + :return: + """ + for engine in cls._user_engine.values(): + await engine.dispose() + cls._user_engine.clear() + + for engine in cls._user_sync_engine.values(): + engine.dispose() + cls._user_sync_engine.clear() + + @classmethod + async def get_user_engine(cls, database_instance: DatabaseInstance) -> AsyncEngine: + """ + 获取用户引擎 + :param database_instance: + :return: + """ + if database_instance.instance_id not in cls._user_engine: + raise InvalidOperationException("请先初始化用户引擎") + + log.info(f"get user engine: {database_instance.instance_id}") + return cls._user_engine[database_instance.instance_id] + + @classmethod + def get_user_sync_engine(cls, database_instance: DatabaseInstance): + """ + 获取用户同步引擎(用于DDL操作) + :param database_instance: + :return: + """ + if database_instance.instance_id not in cls._user_sync_engine: + raise InvalidOperationException("请先初始化用户引擎") + + return cls._user_sync_engine[database_instance.instance_id] + + @classmethod + def is_user_engine_exists(cls, instance_id: int) -> bool: + """ + 判断用户引擎是否存在 + :param instance_id: + :return: + """ + return instance_id in cls._user_engine + + @classmethod + def get_database_file_path(cls, sqlite_config: SQLiteConfig, db_name: str, user_id: Optional[int] = None) -> str: + """ + 获取数据库文件的完整路径,支持用户隔离 + :param sqlite_config: SQLite配置对象 + :param db_name: 数据库名称 + :param user_id: 用户ID,用于隔离不同用户的数据库文件 + :return: 数据库文件的完整路径 + """ + return sqlite_config.get_database_path(db_name, user_id) + + @classmethod + def database_file_exists(cls, sqlite_config: SQLiteConfig, db_name: str) -> bool: + """ + 检查数据库文件是否存在 + :param sqlite_config: SQLite配置对象 + :param db_name: 数据库名称 + :return: 如果数据库文件存在返回True,否则返回False + """ + db_file_path = cls.get_database_file_path(sqlite_config, db_name) + return os.path.exists(db_file_path) + + @classmethod + def remove_database_file(cls, sqlite_config: SQLiteConfig, db_name: str) -> bool: + """ + 删除数据库文件 + :param sqlite_config: SQLite配置对象 + :param db_name: 数据库名称 + :return: 如果删除成功返回True,否则返回False + """ + db_file_path = cls.get_database_file_path(sqlite_config, db_name) + if os.path.exists(db_file_path): + try: + os.remove(db_file_path) + log.info(f"数据库文件 {db_file_path} 已删除") + return True + except Exception as e: + log.error(f"删除数据库文件 {db_file_path} 失败: {e}") + return False + else: + log.warning(f"数据库文件 {db_file_path} 不存在") + return False \ No newline at end of file diff --git a/src/backend/app/sqlite/sqlite_execute.py b/src/backend/app/sqlite/sqlite_execute.py new file mode 100644 index 0000000..e506d59 --- /dev/null +++ b/src/backend/app/sqlite/sqlite_execute.py @@ -0,0 +1,191 @@ +""" +SQLite 执行器。 + +提供用户的 SQL 执行接口,包含 DDL、DML 和 DQL 操作,并集成安全校验。 +""" + +# backend/app/sqlite/sqlite_execute.py + +from sqlalchemy import text +import os +from pathlib import Path +from typing import Optional + +from core.log import log +from models import DatabaseInstance +from sqlite.sqlite_database import SQLiteHelper +from sqlite.sqlite_secure import validate_safe_sql +from core.sql_dialect_converter import SQLDialectConverter +from sqlite.sqlite_converter import SQLiteConverter +from core.exceptions import DatabaseOperationFailedException + + +# 初始化转换器 +generic_converter = SQLDialectConverter() +sqlite_converter = SQLiteConverter() + + +async def execute_dql_user(dql: str, database_instance: DatabaseInstance): + """ + 普通用户执行 DQL (查询)。 + + 使用指定数据库实例的普通用户权限执行查询。会自动转换 SQL 方言并进行安全检查。 + + Args: + dql (str): 待执行的查询语句。 + database_instance (DatabaseInstance): 目标数据库实例对象。 + + Returns: + list[dict]: 查询结果列表。 + """ + # 将SQL转换为SQLite方言 + try: + sqlite_dql = generic_converter.convert(dql, "postgres", "sqlite") # 默认从PostgreSQL转换 + sqlite_dql = sqlite_converter.convert_statement(sqlite_dql) + except Exception as e: + log.warning(f"SQL转换失败,使用原始SQL: {e}") + sqlite_dql = dql + + validate_safe_sql(sqlite_dql, is_root=False) + engine = await SQLiteHelper.get_user_engine(database_instance) + async with engine.connect() as conn: + result = await conn.execute(text(sqlite_dql)) + query_result = result.mappings().fetchall() + return [dict(row) for row in query_result] + + +async def execute_dml_user(dml: str, database_instance: DatabaseInstance): + """ + 普通用户执行 DML (增删改)。 + + 使用指定数据库实例的普通用户权限执行数据变更操作。 + 执行后会自动提交事务。 + + Args: + dml (str): 待执行的 DML 语句 (INSERT/UPDATE/DELETE)。 + database_instance (DatabaseInstance): 目标数据库实例对象。 + + Returns: + dict: 包含受影响行数 (rowcount) 的字典。 + """ + # 将SQL转换为SQLite方言 + try: + sqlite_dml = generic_converter.convert(dml, "postgres", "sqlite") # 默认从PostgreSQL转换 + sqlite_dml = sqlite_converter.convert_statement(sqlite_dml) + except Exception as e: + log.warning(f"SQL转换失败,使用原始SQL: {e}") + sqlite_dml = dml + + validate_safe_sql(sqlite_dml, is_root=False) + engine = await SQLiteHelper.get_user_engine(database_instance) + async with engine.connect() as conn: + result = await conn.execute(text(sqlite_dml)) + await conn.commit() + # SQLite中没有lastrowid属性,所以设置为None + return { + "rowcount": result.rowcount, + "lastrowid": result.lastrowid if hasattr(result, 'lastrowid') else None + } + + +async def deploy_sqlite_ddl_async(db_name: str, statements: list[str], sqlite_config, user_id: Optional[int] = None): + """ + 异步执行的SQLite部署物理实现。 + + 注意:SQLite是文件型数据库,创建数据库即创建文件 + + Args: + db_name: 数据库名称 + statements: SQL语句列表 + sqlite_config: SQLite配置 + user_id: 用户ID,用于隔离不同用户的数据库文件 + """ + try: + # 构建数据库文件路径(支持用户隔离) + db_file_path = sqlite_config.get_database_path(db_name, user_id) + + # 确保目录存在 + Path(db_file_path).parent.mkdir(parents=True, exist_ok=True) + + # 如果数据库文件已存在,先删除 + if os.path.exists(db_file_path): + os.remove(db_file_path) + log.info(f"[SQLite-Physical] 已删除现有数据库文件: {db_file_path}") + + # 创建新的数据库文件(通过创建异步连接) + from sqlalchemy.ext.asyncio import create_async_engine + engine = create_async_engine( + f"sqlite+aiosqlite:///{db_file_path}", + connect_args={"check_same_thread": False}, + echo=sqlite_config.echo + ) + + async with engine.connect() as conn: + # 启用外键约束 + await conn.execute(text("PRAGMA foreign_keys = ON;")) + + # 执行DDL语句 + for stmt in statements: + stmt = stmt.strip() + if not stmt: + continue + + # 跳过数据库创建语句(SQLite不需要) + if stmt.upper().startswith("CREATE DATABASE") or stmt.upper().startswith("USE "): + continue + + log.debug(f"[SQLite-Physical] Executing: {stmt[:50]}...") + await conn.execute(text(stmt)) + + await conn.commit() + + await engine.dispose() + log.info(f"[SQLite-Physical] 部署 {db_name} 完成,文件位置: {db_file_path}") + + return True + except Exception as e: + log.error(f"[SQLite-Physical] Critical Error: {e}") + raise DatabaseOperationFailedException(f"SQLite物理执行失败: {str(e)}") + + +async def deploy_sqlite_ddl(db_name: str, statements: list[str], sqlite_config, user_id: Optional[int] = None): + """ + 具体的 SQLite 部署物理实现。 + + 注意:SQLite是文件型数据库,创建数据库即创建文件 + + Args: + db_name: 数据库名称 + statements: SQL语句列表 + sqlite_config: SQLite配置 + user_id: 用户ID,用于隔离不同用户的数据库文件 + """ + # 使用异步函数执行DDL操作 + return await deploy_sqlite_ddl_async(db_name, statements, sqlite_config, user_id) + + +async def execute_sql_user(sql: str, database_instance: DatabaseInstance): + """ + 普通用户执行 SQL (DDL/DML/DQL)。 + + 使用指定数据库实例的普通用户权限执行SQL语句。 + 执行前会进行 SQL 转换和安全检查。 + + Args: + sql (str): 待执行的 SQL 语句。 + database_instance (DatabaseInstance): 目标数据库实例对象。 + """ + # 将SQL转换为SQLite方言 + try: + sqlite_sql = generic_converter.convert(sql, "postgres", "sqlite") # 默认从PostgreSQL转换 + sqlite_sql = sqlite_converter.convert_statement(sqlite_sql) + except Exception as e: + log.warning(f"SQL转换失败,使用原始SQL: {e}") + sqlite_sql = sql + + validate_safe_sql(sqlite_sql, is_root=False) + engine = await SQLiteHelper.get_user_engine(database_instance) + async with engine.connect() as conn: + await conn.execute(text(sqlite_sql)) + await conn.commit() + log.info(f"用户执行SQL成功: {sqlite_sql}") \ No newline at end of file diff --git a/src/backend/app/sqlite/sqlite_secure.py b/src/backend/app/sqlite/sqlite_secure.py new file mode 100644 index 0000000..c3240ea --- /dev/null +++ b/src/backend/app/sqlite/sqlite_secure.py @@ -0,0 +1,80 @@ +""" +SQLite 安全模块。 + +提供 SQL 语句的安全校验、标准化及权限检查功能,防止 SQL 注入和越权操作。 +""" + +# backend/app/sqlite/sqlite_secure.py + +import re +from typing import List + +from core.exceptions import SQLSecurityException +from core.config import config + +def normalize_sql(sql: str) -> str: + """ + 标准化 SQL 语句。 + + 将 SQL 语句统一转换为大写,并去除多余的空格、换行符和注释, + 以便于后续的安全规则匹配。 + + Args: + sql (str): 原始 SQL 语句。 + + Returns: + str: 标准化后的 SQL 字符串。 + """ + # 去除注释(-- 单行注释) + sql = re.sub(r"--.*?$", "", sql, flags=re.MULTILINE) + # 去除换行/多个空格 → 单个空格 + sql = re.sub(r"\s+", " ", sql.strip()) + # 转大写 + return sql.upper() + +def match_operation(sql: str, operation_patterns: List[str]) -> bool: + """ + 匹配 SQL 是否包含指定操作模式(支持 * 通配符)。 + + Args: + sql (str): 标准化后的 SQL 字符串。 + operation_patterns (List[str]): 操作模式列表(如 ["CREATE DATABASE *", "DROP *"])。 + + Returns: + bool: 如果匹配到任意模式则返回 True,否则返回 False。 + """ + for pattern in operation_patterns: + # 把通配符 * 转为正则匹配(匹配任意字符) + regex_pattern = pattern.replace("*", ".*?").replace(" ", r"\s+") + if re.search(regex_pattern, sql, flags=re.IGNORECASE): + return True + return False + +def validate_safe_sql(sql: str, is_root: bool) -> None: + """ + 执行 SQL 安全权限检查。 + + 根据用户角色(Root 或普通用户)检查 SQL 语句是否包含被禁止的操作, + 或者是否在允许的操作列表中。 + + Args: + sql (str): 待检查的 SQL 语句。 + is_root (bool): 是否为 Root 用户权限。 + + Raises: + SQLSecurityException: 当操作被禁止或未在允许列表中时抛出。 + """ + normalized_sql = normalize_sql(sql) + + if is_root: + allowed = config.sql_permissions.root.allowed_operations + forbidden = config.sql_permissions.root.forbidden_operations + else: + allowed = config.sql_permissions.normal.allowed_operations + forbidden = config.sql_permissions.normal.forbidden_operations + + if match_operation(normalized_sql, forbidden): + raise SQLSecurityException("操作被禁止") + + if not match_operation(normalized_sql, allowed): + raise SQLSecurityException("操作不被允许") \ No newline at end of file diff --git a/src/backend/app/static/exports/knowledge_proj_2_1765025583.xlsx b/src/backend/app/static/exports/knowledge_proj_2_1765025583.xlsx new file mode 100644 index 0000000..fd335cd Binary files /dev/null and b/src/backend/app/static/exports/knowledge_proj_2_1765025583.xlsx differ diff --git a/src/backend/app/static/exports/knowledge_proj_8_1765026436.xlsx b/src/backend/app/static/exports/knowledge_proj_8_1765026436.xlsx new file mode 100644 index 0000000..f05813d Binary files /dev/null and b/src/backend/app/static/exports/knowledge_proj_8_1765026436.xlsx differ diff --git a/src/backend/app/static/exports/knowledge_proj_8_1765026477.xlsx b/src/backend/app/static/exports/knowledge_proj_8_1765026477.xlsx new file mode 100644 index 0000000..7b2d02b Binary files /dev/null and b/src/backend/app/static/exports/knowledge_proj_8_1765026477.xlsx differ diff --git a/src/backend/app/static/exports/knowledge_proj_8_1765027520.xlsx b/src/backend/app/static/exports/knowledge_proj_8_1765027520.xlsx new file mode 100644 index 0000000..9cd0770 Binary files /dev/null and b/src/backend/app/static/exports/knowledge_proj_8_1765027520.xlsx differ diff --git a/src/backend/app/static/exports/knowledge_proj_8_1765027842.xlsx b/src/backend/app/static/exports/knowledge_proj_8_1765027842.xlsx new file mode 100644 index 0000000..249b934 Binary files /dev/null and b/src/backend/app/static/exports/knowledge_proj_8_1765027842.xlsx differ diff --git a/src/backend/app/static/exports/knowledge_proj_8_1765027989.xlsx b/src/backend/app/static/exports/knowledge_proj_8_1765027989.xlsx new file mode 100644 index 0000000..0cd1991 Binary files /dev/null and b/src/backend/app/static/exports/knowledge_proj_8_1765027989.xlsx differ diff --git a/src/backend/app/static/exports/knowledge_proj_8_1765028213.xlsx b/src/backend/app/static/exports/knowledge_proj_8_1765028213.xlsx new file mode 100644 index 0000000..8ccf47f Binary files /dev/null and b/src/backend/app/static/exports/knowledge_proj_8_1765028213.xlsx differ diff --git a/src/backend/app/tasks/__init__.py b/src/backend/app/tasks/__init__.py new file mode 100644 index 0000000..7d8c97e --- /dev/null +++ b/src/backend/app/tasks/__init__.py @@ -0,0 +1,26 @@ +# backend/app/tasks/__init__.py + +""" +Celery 任务模块 + +在此文件中定义和导入所有 Celery 异步任务 +""" + +# 导入 AI 生成任务 +from tasks.ai_generation_tasks import ( + generate_schema_task, + generate_er_task, + generate_ddl_task, + schema_er_pipeline_task, + ddl_er_parallel_task, + get_task_status, +) + +__all__ = [ + "generate_schema_task", + "generate_er_task", + "generate_ddl_task", + "schema_er_pipeline_task", + "ddl_er_parallel_task", + "get_task_status", +] \ No newline at end of file diff --git a/src/backend/app/tasks/ai_generation_tasks.py b/src/backend/app/tasks/ai_generation_tasks.py new file mode 100644 index 0000000..b309438 --- /dev/null +++ b/src/backend/app/tasks/ai_generation_tasks.py @@ -0,0 +1,650 @@ +# backend/app/tasks/ai_generation_tasks.py + +""" +AI 生成任务模块 + +定义 Schema、DDL、ER 图生成的 Celery 异步任务。 +支持任务链式调用和状态跟踪。 + +任务流程: +1. generate_schema_task: 用户输入 -> 生成 Schema +2. generate_er_task: Schema -> 生成 ER 图(可与 DDL 并行) +3. generate_ddl_task: 用户确认 Schema -> 生成 DDL +4. regenerate_er_task: 用户修改 Schema -> 重新生成 ER 图 +""" + +import asyncio +from typing import Optional, Dict, Any +from celery import shared_task, chain, group +from celery.utils.log import get_task_logger + +from celery_app import celery_app +from core.config import config +from core.database import PsqlHelper +from core.log import log +from crud.crud_project import crud_project +from schema import project as schemas +from service.ai_service import AIService +from redis_client.redis_keys import redis_key_manager +from redis_client.cache_service import cache_service + +logger = get_task_logger(__name__) + + +# ========================================================= +# 辅助函数:同步运行异步代码 +# ========================================================= + +def run_async(coro): + """在同步环境中运行异步协程。""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +async def _update_project_field(project_id: int, **update_fields) -> bool: + """更新项目字段的通用方法,同时清除相关缓存。""" + temp_engine = PsqlHelper._get_async_engine(config.db) + try: + async with PsqlHelper.get_session(temp_engine) as session: + project = await crud_project.update(session, project_id, **update_fields) + user_id = project.user_id if project else None + + # 清除项目相关的 Redis 缓存 + if project and user_id: + cache_invalidated = await _invalidate_project_cache(project_id, user_id) + if not cache_invalidated: + log.warning(f"[Cache] Cache invalidation failed for project {project_id} after field update") + logger.warning(f"[Task] Cache invalidation failed for project {project_id} after field update") + + return True + except Exception as e: + logger.error(f"[Task] Failed to update project {project_id}: {e}") + return False + finally: + await temp_engine.dispose() + + +async def _invalidate_project_cache(project_id: int, user_id: int = None) -> bool: + """清除项目相关的所有缓存。""" + try: + # 1. 清除项目详情缓存 + project_info_key = redis_key_manager.get_project_info_key(project_id) + log.info(f"[Cache] Processing cache invalidation for project {project_id}") + + # 检查缓存键是否存在 + key_exists = await cache_service.exists(project_info_key) + log.info(f"[Cache] Project info cache key {project_info_key} exists: {key_exists}") + + if key_exists: + log.info(f"[Cache] Attempting to delete cache key: {project_info_key}") + cache_deleted = await cache_service.delete(project_info_key) + log.info(f"[Cache] Delete operation result for project info cache {project_info_key}: {cache_deleted}") + + if cache_deleted: + logger.info(f"[Task] Successfully cleared project info cache for project {project_id}") + else: + log.error(f"[Cache] Failed to delete project info cache {project_info_key} despite it existing") + logger.error(f"[Task] Failed to clear project info cache for project {project_id}") + else: + log.info(f"[Cache] Project info cache key {project_info_key} does not exist, no deletion needed") + cache_deleted = True # 键不存在也算删除成功 + + # 2. 如果有 user_id,清除该用户的所有项目列表缓存 + list_cache_deleted = True + if user_id: + project_list_pattern = redis_key_manager.generate_key( + redis_key_manager.USER_PREFIX, "projects", str(user_id), "*" + ) + log.info(f"[Cache] Attempting to delete cache pattern: {project_list_pattern}") + pattern_deleted_count = await cache_service.delete_pattern(project_list_pattern) + log.info(f"[Cache] Delete pattern operation result for user {user_id} pattern {project_list_pattern}: {pattern_deleted_count} keys deleted") + + if pattern_deleted_count >= 0: + logger.info(f"[Task] Cleared project list cache for user {user_id}") + else: + list_cache_deleted = False + log.warning(f"[Cache] Failed to delete project list cache pattern {project_list_pattern}") + logger.warning(f"[Task] Failed to clear project list cache for user {user_id}") + + # 3. 清除用户信息缓存(包含 used_databases 项目计数) + user_info_key = redis_key_manager.get_user_info_key(user_id) + log.info(f"[Cache] Attempting to delete user info cache key: {user_info_key}") + user_cache_deleted = await cache_service.delete(user_info_key) + log.info(f"[Cache] Delete operation result for user info cache {user_info_key}: {user_cache_deleted}") + + log.info(f"[Cache] Completed cache invalidation for project {project_id}, user {user_id}") + return cache_deleted and list_cache_deleted + except Exception as e: + log.error(f"[Cache] Exception occurred during cache invalidation for project {project_id}: {e}") + logger.warning(f"[Task] Failed to clear cache for project {project_id}: {e}") + return False + + +async def _get_project(project_id: int) -> Optional[Dict[str, Any]]: + """获取项目信息。""" + temp_engine = PsqlHelper._get_async_engine(config.db) + try: + async with PsqlHelper.get_session(temp_engine) as session: + project = await crud_project.get(session, project_id) + if project: + return { + "project_id": project.project_id, + "schema_definition": project.schema_definition, + "description": project.description, + "ddl_statement": project.ddl_statement, + "er_diagram_code": project.er_diagram_code, + "creation_stage": project.creation_stage, + "project_status": project.project_status, + } + return None + finally: + await temp_engine.dispose() + + +# ========================================================= +# 任务 1: Schema 生成 +# ========================================================= + +@celery_app.task( + bind=True, + name="tasks.generate_schema", + max_retries=2, + default_retry_delay=30, + soft_time_limit=300, + time_limit=360 +) +def generate_schema_task( + self, + project_id: int, + requirements: str, + db_name: str, + db_type: str, + ai_model: str = "gpt4" +) -> Dict[str, Any]: + """ + Celery 任务:生成 Schema。 + + Args: + project_id: 项目 ID + requirements: 用户需求描述 + db_name: 数据库名称 + db_type: 数据库类型 + ai_model: AI 模型标识 + + Returns: + 包含生成结果的字典 + """ + logger.info(f"[Task-Schema] Starting for Project {project_id}, task_id={self.request.id}") + + try: + # 调用 AI 服务生成 Schema + schema_res = AIService.generate_schema( + requirements=requirements, + db_name=db_name, + db_type="mysql", # 内部统一用 mysql 格式生成 schema + ai_model=ai_model + ) + + if not schema_res: + logger.error(f"[Task-Schema] Failed to generate schema for Project {project_id}") + # 更新项目状态为失败 + run_async(_update_project_field( + project_id, + creation_stage=schemas.CreationStageEnum.INITIALIZING.value, + project_status="schema_generation_failed" + )) + return { + "success": False, + "project_id": project_id, + "error": "Schema generation returned empty result" + } + + # 保存 Schema 到数据库 + async def save_schema(): + temp_engine = PsqlHelper._get_async_engine(config.db) + try: + async with PsqlHelper.get_session(temp_engine) as session: + async with session.begin(): + project = await crud_project.get(session, project_id) + if project: + current_def = project.schema_definition or {} + current_def['schema'] = schema_res + current_def['generated_db_name'] = db_name + project.schema_definition = current_def + project.creation_stage = schemas.CreationStageEnum.SCHEMA_GENERATED.value + session.add(project) + logger.info(f"[Task-Schema] SUCCESS for Project {project_id}") + + # 事务提交成功后,重新获取最新的项目信息并清除缓存 + async with PsqlHelper.get_session(temp_engine) as session: + project = await crud_project.get(session, project_id) + if project: + log.info(f"[Task-Schema] About to invalidate cache for Project {project_id}, user {project.user_id}") + await _invalidate_project_cache(project_id, project.user_id) + finally: + await temp_engine.dispose() + + run_async(save_schema()) + + return { + "success": True, + "project_id": project_id, + "schema_text": schema_res, + "db_name": db_name, + "ai_model": ai_model + } + + except Exception as e: + logger.error(f"[Task-Schema] Error for Project {project_id}: {e}") + # 重试逻辑 + if self.request.retries < self.max_retries: + raise self.retry(exc=e) + + # 最终失败,更新状态 + run_async(_update_project_field( + project_id, + creation_stage=schemas.CreationStageEnum.INITIALIZING.value, + project_status="schema_generation_failed" + )) + return { + "success": False, + "project_id": project_id, + "error": str(e) + } + + +# ========================================================= +# 任务 2: ER 图生成 +# ========================================================= + +@celery_app.task( + bind=True, + name="tasks.generate_er", + max_retries=2, + default_retry_delay=20, + soft_time_limit=60, + time_limit=90 +) +def generate_er_task( + self, + project_id: int, + schema_text: str, + ai_model: str = "gpt4" +) -> Dict[str, Any]: + """ + Celery 任务:生成 ER 图。 + + ER 图生成失败不影响主流程,仅记录失败状态。 + + Args: + project_id: 项目 ID + schema_text: Schema 文本 + ai_model: AI 模型标识 + + Returns: + 包含生成结果的字典 + """ + logger.info(f"[Task-ER] Starting for Project {project_id}, task_id={self.request.id}") + + try: + # 调用 AI 服务生成 ER 图 + er_code = AIService.generate_mermaid_code( + schema_text=schema_text, + ai_model=ai_model + ) + + if not er_code: + logger.warning(f"[Task-ER] Empty ER code for Project {project_id}") + return { + "success": False, + "project_id": project_id, + "error": "ER diagram generation returned empty result" + } + + # 保存 ER 图到数据库 + run_async(_update_project_field(project_id, er_diagram_code=er_code)) + + # 清除项目详情缓存,确保前端能立即看到最新数据 + async def invalidate_cache(): + temp_engine = PsqlHelper._get_async_engine(config.db) + try: + async with PsqlHelper.get_session(temp_engine) as session: + db_project = await crud_project.get(session, project_id) + if db_project: + log.info(f"[Task-ER] About to invalidate cache for Project {project_id}, user {db_project.user_id}") + await _invalidate_project_cache(project_id, db_project.user_id) + logger.info(f"[Task-ER] Cache invalidated for Project {project_id}") + finally: + await temp_engine.dispose() + + run_async(invalidate_cache()) + + logger.info(f"[Task-ER] SUCCESS for Project {project_id}") + return { + "success": True, + "project_id": project_id, + "er_code": er_code + } + + except Exception as e: + logger.error(f"[Task-ER] Error for Project {project_id}: {e}") + # ER 图生成失败不重试,不影响主流程 + return { + "success": False, + "project_id": project_id, + "error": str(e) + } + + +# ========================================================= +# 任务 3: DDL 生成 +# ========================================================= + +@celery_app.task( + bind=True, + name="tasks.generate_ddl", + max_retries=2, + default_retry_delay=30, + soft_time_limit=150, + time_limit=180 +) +def generate_ddl_task( + self, + project_id: int, + schema_text: str, + requirements: str, + db_type: str, + db_name: str, + ai_model: str = "gpt4" +) -> Dict[str, Any]: + """ + Celery 任务:生成 DDL。 + + Args: + project_id: 项目 ID + schema_text: 用户确认的 Schema 文本 + requirements: 需求描述 + db_type: 数据库类型 + db_name: 数据库名称 + ai_model: AI 模型标识 + + Returns: + 包含生成结果的字典 + """ + logger.info(f"[Task-DDL] Starting for Project {project_id}, task_id={self.request.id}") + + try: + # 调用 AI 服务生成 DDL + ddl_res = AIService.generate_ddl( + schema_text=schema_text, + requirements=requirements, + db_type=db_type, + ai_model=ai_model + ) + + if not ddl_res: + logger.error(f"[Task-DDL] Failed to generate DDL for Project {project_id}") + run_async(_update_project_field( + project_id, + creation_stage=schemas.CreationStageEnum.SCHEMA_GENERATED.value, + project_status="ddl_generation_failed" + )) + return { + "success": False, + "project_id": project_id, + "error": "DDL generation returned empty result" + } + + # 构建完整 DDL(添加数据库创建语句) + if db_type == 'mysql': + full_ddl = f"CREATE DATABASE IF NOT EXISTS `{db_name}`;\nUSE `{db_name}`;\n\n{ddl_res}" + else: + full_ddl = ddl_res + + # 保存 DDL 到数据库 + run_async(_update_project_field( + project_id, + ddl_statement=full_ddl, + creation_stage=schemas.CreationStageEnum.DDL_GENERATED.value, + project_status=schemas.ProjectStatusEnum.PENDING_CONFIRMATION.value + )) + + # 清除项目详情缓存,确保前端能立即看到最新数据 + async def invalidate_cache(): + temp_engine = PsqlHelper._get_async_engine(config.db) + try: + async with PsqlHelper.get_session(temp_engine) as session: + db_project = await crud_project.get(session, project_id) + if db_project: + log.info(f"[Task-DDL] About to invalidate cache for Project {project_id}, user {db_project.user_id}") + await _invalidate_project_cache(project_id, db_project.user_id) + logger.info(f"[Task-DDL] Cache invalidated for Project {project_id}") + finally: + await temp_engine.dispose() + + run_async(invalidate_cache()) + + logger.info(f"[Task-DDL] SUCCESS for Project {project_id}") + return { + "success": True, + "project_id": project_id, + "ddl_statement": full_ddl + } + + except Exception as e: + logger.error(f"[Task-DDL] Error for Project {project_id}: {e}") + if self.request.retries < self.max_retries: + raise self.retry(exc=e) + + run_async(_update_project_field( + project_id, + creation_stage=schemas.CreationStageEnum.SCHEMA_GENERATED.value, + project_status="ddl_generation_failed" + )) + return { + "success": False, + "project_id": project_id, + "error": str(e) + } + + +# ========================================================= +# 任务 4: Schema + ER 流水线(创建项目时使用) +# ========================================================= + +@celery_app.task( + bind=True, + name="tasks.schema_er_pipeline", + soft_time_limit=600, + time_limit=660 +) +def schema_er_pipeline_task( + self, + project_id: int, + requirements: str, + db_name: str, + db_type: str, + ai_model: str = "gpt4" +) -> Dict[str, Any]: + """ + Celery 任务:Schema + ER 生成流水线。 + + 流程:生成 Schema -> 生成 ER 图 + + Args: + project_id: 项目 ID + requirements: 用户需求描述 + db_name: 数据库名称 + db_type: 数据库类型 + ai_model: AI 模型标识 + + Returns: + 包含流水线执行结果的字典 + """ + logger.info(f"[Pipeline-Schema-ER] Starting for Project {project_id}") + + # Step 1: 生成 Schema + schema_result = generate_schema_task( + project_id=project_id, + requirements=requirements, + db_name=db_name, + db_type=db_type, + ai_model=ai_model + ) + + if not schema_result.get("success"): + logger.error(f"[Pipeline] Schema generation failed for Project {project_id}") + return { + "success": False, + "project_id": project_id, + "stage": "schema", + "error": schema_result.get("error") + } + + # Step 2: 生成 ER 图 + schema_text = schema_result.get("schema_text", "") + if schema_text: + er_result = generate_er_task( + project_id=project_id, + schema_text=schema_text, + ai_model=ai_model + ) + # ER 图失败不影响整体流程 + if not er_result.get("success"): + logger.warning(f"[Pipeline] ER generation failed for Project {project_id}, continuing...") + + logger.info(f"[Pipeline-Schema-ER] Completed for Project {project_id}") + return { + "success": True, + "project_id": project_id, + "schema_text": schema_text, + "stage": "completed" + } + + +# ========================================================= +# 任务 5: DDL + ER 并行生成(用户确认 Schema 后使用) +# ========================================================= + +@celery_app.task( + bind=True, + name="tasks.ddl_er_parallel", + soft_time_limit=600, + time_limit=660 +) +def ddl_er_parallel_task( + self, + project_id: int, + schema_text: str, + requirements: str, + db_type: str, + db_name: str, + ai_model: str = "gpt4", + regenerate_er: bool = True +) -> Dict[str, Any]: + """ + Celery 任务:DDL 和 ER 图并行生成。 + + 当用户确认或修改 Schema 后调用此任务。 + + Args: + project_id: 项目 ID + schema_text: 用户确认的 Schema 文本 + requirements: 需求描述 + db_type: 数据库类型 + db_name: 数据库名称 + ai_model: AI 模型标识 + regenerate_er: 是否重新生成 ER 图 + + Returns: + 包含执行结果的字典 + """ + logger.info(f"[Pipeline-DDL-ER] Starting for Project {project_id}, regenerate_er={regenerate_er}") + + results = {"project_id": project_id} + + # 生成 DDL(必须) + ddl_result = generate_ddl_task( + project_id=project_id, + schema_text=schema_text, + requirements=requirements, + db_type=db_type, + db_name=db_name, + ai_model=ai_model + ) + results["ddl"] = ddl_result + + # 如果需要重新生成 ER 图 + if regenerate_er: + er_result = generate_er_task( + project_id=project_id, + schema_text=schema_text, + ai_model=ai_model + ) + results["er"] = er_result + + success = ddl_result.get("success", False) + results["success"] = success + + logger.info(f"[Pipeline-DDL-ER] Completed for Project {project_id}, success={success}") + return results + + +# ========================================================= +# 任务状态查询辅助函数 +# ========================================================= + +def get_task_status(task_id: str, project_id: int = None) -> Dict[str, Any]: + """ + 查询 Celery 任务状态,并可选地返回项目的业务状态。 + + Args: + task_id: Celery 任务 ID + project_id: 可选,项目 ID(用于获取项目的 creation_stage) + + Returns: + 任务状态信息字典,包含: + - task_id: 任务 ID + - task_status: Celery 任务状态 (PENDING, STARTED, SUCCESS, FAILURE) + - ready: 任务是否完成 + - successful: 任务是否成功 + - result: 任务结果(成功时) + - error: 错误信息(失败时) + - project_id: 项目 ID(如果提供) + - creation_stage: 项目创建阶段(如果提供 project_id 且任务完成) + """ + result = celery_app.AsyncResult(task_id) + + status_info = { + "task_id": task_id, + "task_status": result.status, # Celery 任务状态 + "ready": result.ready(), + "successful": result.successful() if result.ready() else None, + } + + if result.ready(): + if result.successful(): + task_result = result.result + status_info["result"] = task_result + # 从任务结果中提取 project_id + if task_result and isinstance(task_result, dict): + project_id = task_result.get("project_id", project_id) + else: + status_info["error"] = str(result.result) if result.result else "Unknown error" + + # 如果有 project_id 且任务完成,获取项目的最新 creation_stage + if project_id and result.ready(): + try: + project_info = run_async(_get_project(project_id)) + if project_info: + status_info["project_id"] = project_id + status_info["creation_stage"] = project_info.get("creation_stage") + status_info["project_status"] = project_info.get("project_status") + except Exception as e: + logger.warning(f"Failed to get project info for {project_id}: {e}") + + return status_info diff --git a/src/backend/config.yaml b/src/backend/config.yaml new file mode 100644 index 0000000..89d7dd6 --- /dev/null +++ b/src/backend/config.yaml @@ -0,0 +1,384 @@ +env: dev + +# 本地开发环境 +dev: + # 应用配置 + app: + # 应用名称 + name: Auto Database Deployment + # 应用描述 + description: 自然语言创建、操作数据库 + # 应用接口 + api: /api/v1 + # 应用主机地址 + host: 0.0.0.0 + # 应用端口 + port: 8000 + # uvicorn应用入口 + uvicorn: server:my_app + # 应用版本 + version: 1.0.0 + # 是否自动重载 + reload: true + #新增ai配置 + ai: + modelscope_api_key: "ms-5990c475-35aa-444d-80eb-01c7f0d5719b" + # mermaid的api-key + mermaid_api_key: "sk-VwmLWgJlhLpNCyrP5IcTfvsBZp9VfhQqXfqcz7LH35xn5lhn" + + # 业务数据库配置(PostgresSQL) + db: + # 数据库主机 + host: localhost + # 数据库端口 + port: 5432 + # 数据库名称 + database: auto_db_deployment + # 数据库用户 + username: admin + # 数据库密码 + password: secure_2025 + # 数据库连接驱动 + driver: postgresql+asyncpg + # 是否开启sqlalchemy日志 + echo: true + # sqlalchemy连接池配置 + max_overflow: 10 + # sqlalchemy连接池大小 + pool_size: 50 + # sqlalchemy连接池回收时间 + pool_recycle: 3600 + # sqlalchemy连接池空闲时间 + pool_timeout: 30 + + # Redis配置 + redis: + # Redis主机地址 + host: localhost + # Redis端口 + port: 6379 + # Redis数据库索引 + db: 0 + # Redis密码 + password: null # 开发环境通常不需要密码 + # 连接超时时间(秒) + connect_timeout: 5.0 + # 读取超时时间(秒) + read_timeout: 3.0 + # 写入超时时间(秒) + write_timeout: 3.0 + # 连接池最大连接数 + max_connections: 50 + # 订阅频道 + subscribe_channel: "__keyevent@*:expired" + + # ChromaDB 向量数据库配置 + chroma: + # ChromaDB 服务主机(Docker 模式下为服务名) + host: localhost + # ChromaDB 服务端口 + port: 8100 + # 是否使用 HTTP 客户端模式(false: 内嵌模式,true: 连接远程服务) + use_http_client: true + # 内嵌模式下的持久化目录 + persist_directory: "data/chroma_db" + + # Embedding 服务配置(阿里云百炼) + embedding: + api_key: "sk-4cad797f97824bc792db8bb189d770bc" + base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1" + model: "text-embedding-v4" + dimensions: 1024 + + log: + # 文件日志级别 + file_level: DEBUG + # 控制台日志级别 + console_level: INFO + # 日志保留时间 + retention: 10 days + # 日志轮转 + rotation: 500 MB + + jwt: + # JWT密钥 + secret_key: "dev_secret_key" + # JWT算法 + algorithm: HS256 + # JWT访问令牌有效期 + token_expire_time_seconds: 1209600 # 60 * 60 * 24 * 7 minutes = 2周 + + smtp: + # SMTP服务器 + host: smtp.qq.com + # SMTP端口 + port: 465 + # 发送者名称 + sender_name: "auto_db_deployment" + # 发送者邮箱 + sender: 2803234009@qq.com + # 授权码 + key: "bfujmrhgwroudhei" + # 验证码有效期 + expire_time_seconds: 300 + + mysql: + # 数据库主机 + host: localhost + # 数据库端口 + port: 3306 + # 用户名 + username: root + # 密码 + password: root_secure_2025 + # SSL + ssl: false + # plugin + plugin: mysql_native_password + # 数据库连接驱动 + driver: mysql+aiomysql + # sqlalchemy连接池配置 + max_overflow: 5 + # sqlalchemy连接池大小 + pool_size: 5 + # sqlalchemy连接池回收时间 + pool_recycle: 3600 + # sqlalchemy连接池空闲时间 + pool_timeout: 30 + + postgresql: + # 数据库主机 + host: 127.0.0.1 + # 端口 + port: 2345 + # 用户名 + username: postgres + # 密码 + password: root_secure_2025 + # 数据库连接驱动 + driver: postgresql+asyncpg + # 显示执行SQL + echo: true + # sqlalchemy连接池配置 + max_overflow: 5 + pool_size: 5 + pool_recycle: 3600 + pool_timeout: 30 + + sqlite: + # 数据库连接驱动 + driver: sqlite+aiosqlite + # 数据库文件基础路径 + db_path: ./data/sqlite_dbs + # 显示执行SQL + echo: true + # sqlalchemy连接池配置 + max_overflow: 5 + pool_size: 5 + pool_recycle: 3600 + pool_timeout: 30 + + # SQL权限 + sql_permissions: + # MySQL Root 用户权限 + root: + # 允许的操作(关键字,支持通配符 * 匹配后缀) + allowed_operations: + - "CREATE DATABASE *" + - "DROP DATABASE *" + - "CREATE USER *" + - "DROP USER *" + - "GRANT *" + - "REVOKE *" + - "CREATE TABLE *" + - "ALTER TABLE *" + - "DROP TABLE *" + - "TRUNCATE TABLE *" + - "SELECT *" + - "INSERT *" + - "UPDATE *" + - "DELETE *" + - "CREATE INDEX *" + - "DROP INDEX *" + - "USE *" + # 禁止的操作(Root 仅禁止系统级高危操作) + forbidden_operations: + - "SET GLOBAL *" + - "KILL *" + - "SHOW PROCESSLIST" + # MySQL 普通业务用户权限(仅 Project 内 DML 操作) + normal: + # 允许的操作(仅增删改查+切换自身库) + allowed_operations: + - "SELECT *" + - "INSERT *" + - "UPDATE *" + - "DELETE *" + - "SHOW *" + - "DESCRIBE *" + # 禁止的操作(所有管理型/结构修改操作) + forbidden_operations: + - "^USE *" + - "^CREATE DATABASE *" + - "^DROP DATABASE *" + - "^CREATE USER *" + - "^DROP USER *" + - "^GRANT *" + - "^REVOKE *" + - "^CREATE TABLE *" + - "^ALTER TABLE *" + - "^DROP TABLE *" + - "^TRUNCATE TABLE *" + - "^CREATE INDEX *" + - "^DROP INDEX *" + - "^SET *" + - "^KILL *" + +# 生产环境 +prod: + app: + name: Auto Database Deployment + description: 自然语言创建、操作数据库 + api: /api/v1 + host: 0.0.0.0 + port: 8000 + uvicorn: app.server:app + version: 1.0.0 + reload: true + + db: + host: localhost + port: 5432 + database: auto_db_deployment + username: admin + password: secure_2025 + driver: postgresql+asyncpg + echo: true + max_overflow: 10 + pool_size: 50 + pool_recycle: 3600 + pool_timeout: 30 + + redis: + host: localhost + port: 6379 + db: 0 + password: "secure_redis_2025" + subscribe_channel: "__keyevent@*:expired" + + # ChromaDB 向量数据库配置(生产环境使用 Docker) + chroma: + host: chromadb + port: 8000 + # 生产环境使用 HTTP 客户端连接 Docker 中的 ChromaDB + use_http_client: true + persist_directory: "/chroma/chroma" + + # Embedding 服务配置 + embedding: + api_key: "sk-4cad797f97824bc792db8bb189d770bc" + base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1" + model: "text-embedding-v4" + dimensions: 1024 + + log: + file_level: DEBUG + console_level: WARN + retention: 7 days + rotation: 1 GB + + jwt: + secret_key: "prod_secret_key" # 占位,后面会生成强密钥 + algorithm: HS256 + token_expire_time_seconds: 1209600 # 60 * 60 * 24 * 7 minutes = 2周 + + smtp: + host: smtp.qq.com + port: 465 + sender_name: "auto_db_deployment" + sender: 2803234009@qq.com + key: "bfujmrhgwroudhei" + expire_time_seconds: 300 + + mysql: + host: localhost + port: 3306 + username: root + password: root_secure_2025 + ssl: false + plugin: mysql_native_password + driver: mysql+aiomysql + max_overflow: 10 + pool_size: 50 + pool_recycle: 3600 + pool_timeout: 30 + + postgresql: + host: localhost + port: 2345 + username: root + password: root_secure_2025 + driver: postgresql+asyncpg + echo: false + max_overflow: 5 + pool_size: 5 + pool_recycle: 3600 + pool_timeout: 30 + + sqlite: + driver: sqlite+aiosqlite + db_path: ./data/sqlite_dbs + echo: false + max_overflow: 5 + pool_size: 5 + pool_recycle: 3600 + pool_timeout: 30 + + sql_permissions: + root: + allowed_operations: + - "CREATE DATABASE *" + - "DROP DATABASE *" + - "CREATE USER *" + - "DROP USER *" + - "GRANT *" + - "REVOKE *" + - "CREATE TABLE *" + - "ALTER TABLE *" + - "DROP TABLE *" + - "TRUNCATE TABLE *" + - "SELECT *" + - "INSERT *" + - "UPDATE *" + - "DELETE *" + - "CREATE INDEX *" + - "DROP INDEX *" + - "USE *" + forbidden_operations: + - "SET GLOBAL *" + - "KILL *" + - "SHOW PROCESSLIST" + normal: + allowed_operations: + - "SELECT *" + - "INSERT *" + - "UPDATE *" + - "DELETE *" + - "SHOW *" + - "DESCRIBE *" + forbidden_operations: + - "^USE *" + - "^CREATE DATABASE *" + - "^DROP DATABASE *" + - "^CREATE USER *" + - "^DROP USER *" + - "^GRANT *" + - "^REVOKE *" + - "^CREATE TABLE *" + - "^ALTER TABLE *" + - "^DROP TABLE *" + - "^TRUNCATE TABLE *" + - "^CREATE INDEX *" + - "^DROP INDEX *" + - "^SET *" + - "^KILL *" \ No newline at end of file diff --git a/src/backend/ddl.py b/src/backend/ddl.py new file mode 100644 index 0000000..d76507d --- /dev/null +++ b/src/backend/ddl.py @@ -0,0 +1,11 @@ +import requests + +url = "http://10.157.197.76:8000/generate/ddl" +data = { + "database_requirment": "A university needs a student course selection management system to maintain and track students' course selection information. Students have\ninformation such as student ID, name, age, the name of the course chosen by the student, etc. Each student can take multiple courses and can drop or\nchange courses within the specified time. Each course has information such as course number, course name, credits, lecturer and class time. The\npopularity of a course depends on the number of students who take the course. The system can predict the popularity of the course and provide support\nfor academic decision-making", + "schema": "(1) Student\n- Attribute: student ID, name, age\n- Primary Key: student ID\n\n(2) Course\n- Attribute: course number, course name, credits, lecturer, class time\n- Primary Key: course number\n\n(3) StudentCourses\n- Attribute: student ID, course number\n- Primary Key: student ID, course number\n- Foreign Key: student ID (reference Student: student ID), course number (reference Course: course number)\n ", + "target_db_type": "postgresql", + "model": "gpt4" +} +response = requests.post(url, json=data) +print(response.json()) diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt new file mode 100644 index 0000000..35f47fc --- /dev/null +++ b/src/backend/requirements.txt @@ -0,0 +1,66 @@ +# Web框架 +fastapi==0.104.1 +uvicorn[standard]==0.24.0 + +# 数据库相关 +sqlalchemy==2.0.23 +alembic==1.12.1 +psycopg2-binary==2.9.9 +asyncpg==0.29.0 +PyMySQL==1.1.0 +aiomysql==0.2.0 +aiosqlite==0.19.0 + +# Redis & 异步任务 +redis==4.6.0 +aioredis==2.0.1 +celery[redis]==5.3.4 + +# 认证与安全 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-multipart==0.0.6 +cryptography==41.0.7 + +# 数据验证与配置 +pydantic==2.4.2 +pydantic-settings==2.1.0 +python-dotenv==1.0.0 + +# HTTP客户端 +httpx==0.28.1 +aiohttp==3.9.1 + +# SQL安全校验 +sqlglot==24.0.0 + +# 日志 +loguru==0.7.2 +structlog==23.2.0 +sentry-sdk[fastapi]==1.40.0 + +# 测试工具 +pytest==7.4.3 +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 + +# 开发工具 +black==23.11.0 +isort==5.12.0 +flake8==6.1.0 + +# 其他工具 +python-dateutil==2.8.2 +pytz==2023.3 +tenacity==8.2.3 +email-validator +aiosmtplib==2.0.1 +sqlparse==0.5.0 +beautifulsoup4==4.12.2 +requests==2.32.3 +pypinyin==0.55.0 +pandas==2.3.3 +openpyxl==3.1.5 +openai==2.12.0 +chromadb==0.5.23 \ No newline at end of file diff --git a/src/backend/setup.py b/src/backend/setup.py new file mode 100644 index 0000000..1768933 --- /dev/null +++ b/src/backend/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup, find_packages + +setup( + name="auto_db_deployment", # 项目名称(自定义) + version="1.0.0", # 版本号(自定义) + packages=find_packages(where="app"), # 从 app 目录查找所有包 + package_dir={"": "app"}, +) \ No newline at end of file diff --git a/src/backend/test_violation_dedup.py b/src/backend/test_violation_dedup.py new file mode 100644 index 0000000..40cb70f --- /dev/null +++ b/src/backend/test_violation_dedup.py @@ -0,0 +1,217 @@ +""" +测试违规日志去重逻辑 +验证同一用户同一类型的违规只显示最新的一条 +""" + +import asyncio +import sys +import os +from datetime import datetime, timezone, timedelta + +# 添加项目路径 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app')) + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy import select + +from models.user_account import UserAccount +from models.violation_log import ViolationLog +from crud.crud_admin_data import crud_admin_data +from core.database import Base + + +async def test_violation_deduplication(): + """测试违规日志去重""" + + print("\n" + "=" * 70) + print("测试:同一用户同一类型违规只显示最新一条") + print("=" * 70) + + # 创建测试数据库 + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo=False + ) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async_session = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async with async_session() as db: + # 创建测试用户 + user1 = UserAccount( + username="test_user1", + email="user1@example.com", + password_hash="hashed", + status="normal" + ) + user2 = UserAccount( + username="test_user2", + email="user2@example.com", + password_hash="hashed", + status="normal" + ) + db.add_all([user1, user2]) + await db.commit() + await db.refresh(user1) + await db.refresh(user2) + + print(f"\n✓ 创建测试用户:") + print(f" - {user1.username} (ID: {user1.user_id})") + print(f" - {user2.username} (ID: {user2.user_id})") + + # 创建测试违规日志 + print("\n" + "-" * 70) + print("创建测试违规日志") + print("-" * 70) + + now = datetime.now(timezone.utc) + + # user1 的多次登录失败记录(应该只显示最新的一条) + violations = [ + ViolationLog( + user_id=user1.user_id, + event_type="multiple_failed_logins", + event_description="第1次记录", + risk_level="HIGH", + ip_address="192.168.1.1", + resolution_status="pending", + created_at=now - timedelta(minutes=10) + ), + ViolationLog( + user_id=user1.user_id, + event_type="multiple_failed_logins", + event_description="第2次记录", + risk_level="HIGH", + ip_address="192.168.1.1", + resolution_status="pending", + created_at=now - timedelta(minutes=5) + ), + ViolationLog( + user_id=user1.user_id, + event_type="multiple_failed_logins", + event_description="第3次记录(最新)", + risk_level="HIGH", + ip_address="192.168.1.1", + resolution_status="pending", + created_at=now + ), + # user1 的API频率超限记录 + ViolationLog( + user_id=user1.user_id, + event_type="excessive_api_usage", + event_description="API频率超限", + risk_level="MEDIUM", + ip_address="192.168.1.1", + resolution_status="pending", + created_at=now - timedelta(minutes=3) + ), + # user2 的多次登录失败记录 + ViolationLog( + user_id=user2.user_id, + event_type="multiple_failed_logins", + event_description="user2的登录失败", + risk_level="HIGH", + ip_address="192.168.1.2", + resolution_status="pending", + created_at=now - timedelta(minutes=2) + ), + ] + + db.add_all(violations) + await db.commit() + + print(f"\n✓ 创建了 {len(violations)} 条违规日志:") + print(f" - user1: 3条多次登录失败 + 1条API频率超限") + print(f" - user2: 1条多次登录失败") + + # 查询所有违规日志(不去重) + print("\n" + "-" * 70) + print("查询所有违规日志(不去重)") + print("-" * 70) + + result = await db.execute( + select(ViolationLog).order_by(ViolationLog.created_at.desc()) + ) + all_violations = result.scalars().all() + + print(f"\n总记录数: {len(all_violations)}") + for v in all_violations: + print(f" - {v.event_type} | user_id={v.user_id} | {v.event_description}") + + # 使用去重查询 + print("\n" + "-" * 70) + print("使用去重查询(同一用户同一类型只显示最新)") + print("-" * 70) + + dedup_logs, total = await crud_admin_data.get_violation_logs( + db=db, + page=1, + page_size=20, + risk_level=None, + resolution_status=None + ) + + print(f"\n去重后记录数: {len(dedup_logs)}") + for log in dedup_logs: + print(f" - {log['event_type']} | user_id={log['user_id']} | {log['event_description']}") + + # 验证结果 + print("\n" + "=" * 70) + print("验证结果") + print("=" * 70) + + # 应该有3条记录: + # 1. user1的最新多次登录失败 + # 2. user1的API频率超限 + # 3. user2的多次登录失败 + expected_count = 3 + + if len(dedup_logs) == expected_count: + print(f"✅ 测试通过:去重后有 {expected_count} 条记录") + + # 验证user1的多次登录失败是最新的那条 + user1_login_fail = [log for log in dedup_logs + if log['user_id'] == user1.user_id + and log['event_type'] == 'multiple_failed_logins'] + + if len(user1_login_fail) == 1: + if "最新" in user1_login_fail[0]['event_description']: + print("✅ user1的多次登录失败显示的是最新记录") + else: + print(f"❌ user1的多次登录失败不是最新记录: {user1_login_fail[0]['event_description']}") + else: + print(f"❌ user1的多次登录失败记录数错误: {len(user1_login_fail)}") + + # 验证user1的API频率超限 + user1_api = [log for log in dedup_logs + if log['user_id'] == user1.user_id + and log['event_type'] == 'excessive_api_usage'] + + if len(user1_api) == 1: + print("✅ user1的API频率超限记录正常") + else: + print(f"❌ user1的API频率超限记录数错误: {len(user1_api)}") + + # 验证user2的记录 + user2_logs = [log for log in dedup_logs if log['user_id'] == user2.user_id] + + if len(user2_logs) == 1: + print("✅ user2的记录正常") + else: + print(f"❌ user2的记录数错误: {len(user2_logs)}") + + else: + print(f"❌ 测试失败:去重后有 {len(dedup_logs)} 条记录,预期 {expected_count} 条") + + print("=" * 70 + "\n") + + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(test_violation_deduplication()) diff --git a/src/backend/tests/ai/test.py b/src/backend/tests/ai/test.py new file mode 100644 index 0000000..af6fa5f --- /dev/null +++ b/src/backend/tests/ai/test.py @@ -0,0 +1,11 @@ +import requests + +url = "https://schema2ddl.strangeloop.fun/generate/ddl" +data = { + "database_requirment": "A university needs a student course selection management system to maintain and track students' course selection information. Students have\ninformation such as student ID, name, age, the name of the course chosen by the student, etc. Each student can take multiple courses and can drop or\nchange courses within the specified time. Each course has information such as course number, course name, credits, lecturer and class time. The\npopularity of a course depends on the number of students who take the course. The system can predict the popularity of the course and provide support\nfor academic decision-making", + "schema": "(1) Student\n- Attribute: student ID, name, age\n- Primary Key: student ID\n\n(2) Course\n- Attribute: course number, course name, credits, lecturer, class time\n- Primary Key: course number\n\n(3) StudentCourses\n- Attribute: student ID, course number\n- Primary Key: student ID, course number\n- Foreign Key: student ID (reference Student: student ID), course number (reference Course: course number)\n ", + "target_db_type": "mysql", + "model": "gpt4" +} +response = requests.post(url, json=data) +print(response.text) \ No newline at end of file diff --git a/src/backend/tests/ai/test_convert_to_spider_format.py b/src/backend/tests/ai/test_convert_to_spider_format.py new file mode 100644 index 0000000..04bcd0e --- /dev/null +++ b/src/backend/tests/ai/test_convert_to_spider_format.py @@ -0,0 +1,77 @@ +import json +import os + +# ================= 配置 ================= +# 输入:之前生成的 DML 数据 (Alpaca 格式) +INPUT_DML_FILE = "./spider_dml_dev.json" + +# 输出:转换后的 DML 数据 (Spider 格式) +# 建议改个名字,表明它只包含 DML 数据 +OUTPUT_FILE = "./spider_dml_dev_converted.json" +# ======================================= + +def convert_dml_to_spider_format(dml_data): + """ + 将 Alpaca 格式 (instruction/output) 转换为 Spider 格式 (question/query/sql) + """ + spider_style_data = [] + + for item in dml_data: + # 提取核心字段 + question = item.get('instruction', '') + sql_query = item.get('output', '') + db_id = item.get('db_id', '') + + # 简单的分词 (模拟 Spider 的 toks) + q_toks = question.split() + sql_toks = sql_query.split() + + entry = { + "db_id": db_id, + "query": sql_query, + "query_toks": sql_toks, + "query_toks_no_value": sql_toks, + "question": question, + "question_toks": q_toks, + # 空的 sql 解析树结构,骗过训练代码的检查 + "sql": { + "from": {"table_units": [], "conds": []}, + "select": [], + "where": [], + "groupBy": [], + "having": [], + "orderBy": [], + "limit": None, + "intersect": None, + "union": None, + "except": None + } + } + spider_style_data.append(entry) + + return spider_style_data + +def main(): + # 1. 读取生成的 DML 数据 + if not os.path.exists(INPUT_DML_FILE): + print(f"Error: {INPUT_DML_FILE} not found. Please generate it first.") + return + + print(f"Loading generated DML data from {INPUT_DML_FILE}...") + with open(INPUT_DML_FILE, 'r', encoding='utf-8') as f: + dml_data = json.load(f) + + # 2. 转换格式 + print("Converting DML data to Spider format...") + dml_spider_format = convert_dml_to_spider_format(dml_data) + print(f"Converted {len(dml_spider_format)} DML entries.") + + # 3. 保存 (不再合并其他文件) + with open(OUTPUT_FILE, 'w', encoding='utf-8') as f: + json.dump(dml_spider_format, f, indent=4, ensure_ascii=False) + + print(f"Successfully saved separate DML dataset to {OUTPUT_FILE}") + print("Now update your config.py to include this file in the train_file list.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/backend/tests/ai/test_embedding.py b/src/backend/tests/ai/test_embedding.py new file mode 100644 index 0000000..ac43c75 --- /dev/null +++ b/src/backend/tests/ai/test_embedding.py @@ -0,0 +1,16 @@ +import os +from openai import OpenAI + +client = OpenAI( + api_key="sk-4cad797f97824bc792db8bb189d770bc", + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1" # 百炼服务的base_url +) + +completion = client.embeddings.create( + model="text-embedding-v4", + input='衣服的质量杠杠的,很漂亮,不枉我等了这么久啊,喜欢,以后还来这里买', + dimensions=1024, # 指定向量维度(仅 text-embedding-v3及 text-embedding-v4支持该参数) + encoding_format="float" +) + +print(completion.model_dump_json()) \ No newline at end of file diff --git a/src/backend/tests/ai/test_generate_dml_dataset.py b/src/backend/tests/ai/test_generate_dml_dataset.py new file mode 100644 index 0000000..4e4b651 --- /dev/null +++ b/src/backend/tests/ai/test_generate_dml_dataset.py @@ -0,0 +1,261 @@ +import json +import os +import httpx +import asyncio +from tqdm import tqdm + +# ================= 配置区域 ================= +# 必须确保这些文件存在 (保持原有的相对路径) +SPIDER_TABLES_PATH = "../tables.json" +SPIDER_TRAIN_PATH = "../train_spider.json" +# SPIDER_OTHERS_PATH = "../train_others.json" + +# 输出文件 +OUTPUT_FILE = "./spider_dml_extended.json" + +# AI 服务配置 +# 【修复点】URL 必须包含 /v1/chat/completions +# AI_SERVICE_URL = "https://api-inference.modelscope.cn/v1/chat/completions" +# AI_API_KEY = "ms-5990c475-35aa-444d-80eb-01c7f0d5719b" +AI_SERVICE_URL = "https://ai.nengyongai.cn/v1/chat/completions" +AI_API_KEY = "sk-VwmLWgJlhLpNCyrP5IcTfvsBZp9VfhQqXfqcz7LH35xn5lhn" +AI_MODEL = "qwen-turbo" + +# 并发数 +CONCURRENCY_LIMIT = 1 +# =========================================== + +# 修改后的 SYSTEM_PROMPT +SYSTEM_PROMPT = """You are a SQL dataset generator. +Your task is to generate pairs of "Natural Language Instruction" and "SQL Command" based on the provided database schema. + +### STRICT RULES FOR SQL GENERATION: + +1. **Focus ONLY on DML**: Generate INSERT, UPDATE, and DELETE statements. NO SELECT. + +2. **Handling Primary Keys (PK) - CRITICAL**: + - **CASE 1: Numeric PK** (e.g., id int): TREAT AS AUTO-INCREMENT. **DO NOT** insert value. + - Correct: INSERT INTO Student (Name) VALUES ('John'); + - **CASE 2: Text/String PK** (e.g., id varchar, code text): **YOU MUST GENERATE A VALUE**. + - Correct: INSERT INTO Course (Course_Code, Name) VALUES ('CS101', 'Intro to CS'); + - Wrong: INSERT INTO Course (Name) VALUES ('Intro to CS'); + - **CASE 3: Composite/Foreign Keys**: Always insert values for foreign keys and junction tables. + +3. **Real-world Business Logic (User Intent)**: + - Users rarely know IDs (e.g., "Delete ID 5"). They use names, titles, or codes. + - **Instruction**: Use natural language references. + - Good: "Delete the student named John Doe." + - Good: "Update the price of the 'Basic Plan'." + - Avoid: "Delete student with ID 5." (Unless testing specific ID logic) + - **WHERE Clause**: Match the Instruction. + - If instruction says "John Doe", SQL must use `WHERE Name = 'John Doe'`. + - Do NOT look up the ID internally to use in the SQL unless the user explicitly provided the ID. + +4. **Diversity**: Mix simple conditions and specific business logic. +""" + +USER_TEMPLATE = """ +Here is the schema for the database "{db_id}": +{schema_text} + +Please generate {count} diverse pairs of (Instruction, SQL) covering INSERT, UPDATE, and DELETE operations. +Make sure the SQL is valid based on the schema provided. +The "Instruction" should sound like a real user request. + +Return the result strictly in valid JSON format like this list: +[ + {{"instruction": "Delete the student with ID 1", "output": "DELETE FROM student WHERE id = 1;"}}, + {{"instruction": "Change the name of course CS101 to 'Intro to AI'", "output": "UPDATE course SET name = 'Intro to AI' WHERE course_code = 'CS101';"}} +] +""" + +def get_train_db_ids(): + """获取所有训练集涉及的数据库 ID (白名单)""" + db_ids = set() + + # 1. 读取 train_spider.json + if os.path.exists(SPIDER_TRAIN_PATH): + with open(SPIDER_TRAIN_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + for item in data: + db_ids.add(item['db_id']) + + # # 2. 读取 train_others.json + # if os.path.exists(SPIDER_OTHERS_PATH): + # with open(SPIDER_OTHERS_PATH, 'r', encoding='utf-8') as f: + # data = json.load(f) + # for item in data: + # db_ids.add(item['db_id']) + + print(f"Found {len(db_ids)} unique databases in training sets.") + return db_ids + +def parse_spider_schema(table_entry): + """将 Spider 的 tables.json 条目转换为文本 Schema""" + db_id = table_entry['db_id'] + table_names = table_entry['table_names_original'] + column_names = table_entry['column_names_original'] + column_types = table_entry['column_types'] + primary_keys = table_entry['primary_keys'] + foreign_keys = table_entry['foreign_keys'] + + tables = {} + for idx, name in enumerate(table_names): + tables[idx] = {"name": name, "columns": []} + + for col_idx, (table_idx, col_name) in enumerate(column_names): + if table_idx == -1: continue + col_type = column_types[col_idx] + is_pk = col_idx in primary_keys + + col_desc = f"{col_name} ({col_type})" + if is_pk: col_desc += " PK" + tables[table_idx]["columns"].append(col_desc) + + schema_lines = [] + for t_info in tables.values(): + cols_str = ", ".join(t_info["columns"]) + schema_lines.append(f"Table {t_info['name']}: [{cols_str}]") + + if foreign_keys: + fk_lines = [] + for src, tgt in foreign_keys: + try: + src_table = column_names[src][0] + src_col = column_names[src][1] + tgt_table = column_names[tgt][0] + tgt_col = column_names[tgt][1] + fk_lines.append(f"FK: {table_names[src_table]}.{src_col} -> {table_names[tgt_table]}.{tgt_col}") + except: pass + if fk_lines: + schema_lines.append("Relationships: " + "; ".join(fk_lines)) + + return "\n".join(schema_lines) + +async def generate_for_db(client, db_entry, semaphore): + async with semaphore: + db_id = db_entry['db_id'] + schema_text = parse_spider_schema(db_entry) + + prompt = USER_TEMPLATE.format(db_id=db_id, schema_text=schema_text, count=30) + + payload = { + "model": AI_MODEL, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": prompt} + ], + "temperature": 0.3, + "max_tokens": 8192 + } + + try: + # 超时时间设长一点,防止生成中断 + response = await client.post(AI_SERVICE_URL, json=payload, headers={"Authorization": f"Bearer {AI_API_KEY}"}, timeout=90) + + # 检查 HTTP 状态码 + if response.status_code != 200: + print(f"Error generating for {db_id}: HTTP {response.status_code} - {response.text[:100]}") + return [] + + # 解析响应 + try: + resp_json = response.json() + content = resp_json['choices'][0]['message']['content'] + except (json.JSONDecodeError, KeyError, IndexError) as e: + # 【新增】详细错误打印,帮助定位问题 + print(f"Error parsing API response for {db_id}: {e}") + print(f"Raw Response: {response.text[:200]}...") # 打印前200个字符看看是什么 + return [] + + # 1. 清洗 Markdown 标记 (保持原有的逻辑) + if "```json" in content: + content = content.split("```json")[1].split("```")[0] + elif "```" in content: + content = content.split("```")[1].split("```")[0] + + # 2. 去除首尾空白 + content = content.strip() + + # === 【新增】修复非法转义字符 === + # 将 JSON 中非法的 \' 替换为合法的 ' + # 注意:使用 r"\'" 表示原始字符串 + content = content.replace(r"\'", "'") + # ============================== + + # 3. 解析 JSON + try: + # strict=False 允许字符串中包含控制字符 (解决 Invalid control character) + data_pairs = json.loads(content, strict=False) + except json.JSONDecodeError as e: + # 如果还是报错,尝试一种更暴力的清洗方式(仅作为后备方案) + print(f"Standard parse failed for {db_id}, trying fallback cleanup...") + try: + # 仅在非结构位置替换(极简处理:假设非法字符只出现在字符串值里) + # 注意:这仍然有风险,但在数据生成脚本中通常够用 + import re + # 移除所有非 JSON 结构的换行符(这很难完美做到,不如直接跳过) + # 建议:如果 strict=False 还是挂了,打印内容人工检查,或者直接让模型重试 + print(f"FAILED CONTENT PREVIEW: {content[:100]}...") + print(f"Raw Content: {content}") + return [] + except: + return [] + + formatted_data = [] + for item in data_pairs: + formatted_data.append({ + "instruction": item['instruction'], + "input": f"Schema:\n{schema_text}", + "output": item['output'], + "db_id": db_id, + "type": "DML_generated" + }) + + return formatted_data + + except Exception as e: + # 打印错误但不中断整个过程 + print(f"Error generating for {db_id}: {e}") + return [] + +async def main(): + if not os.path.exists(SPIDER_TABLES_PATH): + print(f"Error: {SPIDER_TABLES_PATH} not found.") + return + + # 1. 获取训练集白名单 + train_db_ids = get_train_db_ids() + if not train_db_ids: + print("Error: No training databases found. Check your train_spider/others paths.") + return + + # 2. 读取所有 Tables + print("Loading Spider tables...") + with open(SPIDER_TABLES_PATH, 'r', encoding='utf-8') as f: + all_tables = json.load(f) + + # 3. 过滤:只保留训练集里的数据库 + target_dbs = [db for db in all_tables if db['db_id'] in train_db_ids] + print(f"Generating DML data for {len(target_dbs)} databases (filtered from {len(all_tables)} total)...") + + # 4. 并发生成 + results = [] + semaphore = asyncio.Semaphore(CONCURRENCY_LIMIT) + + async with httpx.AsyncClient() as client: + tasks = [generate_for_db(client, db, semaphore) for db in target_dbs] + for f in tqdm(asyncio.as_completed(tasks), total=len(tasks)): + batch = await f + results.extend(batch) + + print(f"Generation complete. Total items: {len(results)}") + + # 5. 保存 + with open(OUTPUT_FILE, 'w', encoding='utf-8') as f: + json.dump(results, f, indent=2, ensure_ascii=False) + + print(f"Saved to {OUTPUT_FILE}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/src/backend/tests/ai/test_generate_dml_dev.py b/src/backend/tests/ai/test_generate_dml_dev.py new file mode 100644 index 0000000..ea9f744 --- /dev/null +++ b/src/backend/tests/ai/test_generate_dml_dev.py @@ -0,0 +1,252 @@ +import json +import os +import httpx +import asyncio +from tqdm import tqdm + +# ================= 配置区域 ================= +# 必须确保这些文件存在 (保持原有的相对路径) +SPIDER_TABLES_PATH = "../tables.json" +SPIDER_DEV_PATH = "../dev.json" # 指向 Spider 的 dev.json +# SPIDER_OTHERS_PATH = "../train_others.json" + +# 输出文件 +OUTPUT_FILE = "./spider_dml_dev.json" # 输出到新的 dev 文件 + +# AI 服务配置 +# /v1/chat/completions +AI_SERVICE_URL = "https://ai.nengyongai.cn/v1/chat/completions" +AI_API_KEY = "sk-VwmLWgJlhLpNCyrP5IcTfvsBZp9VfhQqXfqcz7LH35xn5lhn" +AI_MODEL = "qwen-turbo" + + +# 并发数 +CONCURRENCY_LIMIT = 1 +# =========================================== + +SYSTEM_PROMPT = """You are a SQL dataset generator. +Your task is to generate pairs of "Natural Language Instruction" and "SQL Command" based on the provided database schema. + +### STRICT RULES FOR SQL GENERATION: + +1. **Focus ONLY on DML**: Generate INSERT, UPDATE, and DELETE statements. NO SELECT. + +2. **Handling Primary Keys (PK) - CRITICAL**: + - **CASE 1: Numeric PK** (e.g., id int): TREAT AS AUTO-INCREMENT. **DO NOT** insert value. + - Correct: INSERT INTO Student (Name) VALUES ('John'); + - **CASE 2: Text/String PK** (e.g., id varchar, code text): **YOU MUST GENERATE A VALUE**. + - Correct: INSERT INTO Course (Course_Code, Name) VALUES ('CS101', 'Intro to CS'); + - Wrong: INSERT INTO Course (Name) VALUES ('Intro to CS'); + - **CASE 3: Composite/Foreign Keys**: Always insert values for foreign keys and junction tables. + +3. **Real-world Business Logic (User Intent)**: + - Users rarely know IDs (e.g., "Delete ID 5"). They use names, titles, or codes. + - **Instruction**: Use natural language references. + - Good: "Delete the student named John Doe." + - Good: "Update the price of the 'Basic Plan'." + - Avoid: "Delete student with ID 5." (Unless testing specific ID logic) + - **WHERE Clause**: Match the Instruction. + - If instruction says "John Doe", SQL must use `WHERE Name = 'John Doe'`. + - Do NOT look up the ID internally to use in the SQL unless the user explicitly provided the ID. + +4. **Diversity**: Mix simple conditions and specific business logic. +""" + +USER_TEMPLATE = """ +Here is the schema for the database "{db_id}": +{schema_text} + +Please generate {count} diverse pairs of (Instruction, SQL) covering INSERT, UPDATE, and DELETE operations. +Make sure the SQL is valid based on the schema provided. +The "Instruction" should sound like a real user request. + +Return the result strictly in valid JSON format like this list: +[ + {{"instruction": "Delete the student with ID 1", "output": "DELETE FROM student WHERE id = 1;"}}, + {{"instruction": "Change the name of course CS101 to 'Intro to AI'", "output": "UPDATE course SET name = 'Intro to AI' WHERE course_code = 'CS101';"}} +] +""" + +def get_dev_db_ids(): + """获取所有 Dev 集涉及的数据库 ID""" + db_ids = set() + + # 修改这里读取 SPIDER_DEV_PATH + if os.path.exists(SPIDER_DEV_PATH): + with open(SPIDER_DEV_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + for item in data: + db_ids.add(item['db_id']) + + print(f"Found {len(db_ids)} unique databases in DEV set.") + return db_ids + +def parse_spider_schema(table_entry): + """将 Spider 的 tables.json 条目转换为文本 Schema""" + db_id = table_entry['db_id'] + table_names = table_entry['table_names_original'] + column_names = table_entry['column_names_original'] + column_types = table_entry['column_types'] + primary_keys = table_entry['primary_keys'] + foreign_keys = table_entry['foreign_keys'] + + tables = {} + for idx, name in enumerate(table_names): + tables[idx] = {"name": name, "columns": []} + + for col_idx, (table_idx, col_name) in enumerate(column_names): + if table_idx == -1: continue + col_type = column_types[col_idx] + is_pk = col_idx in primary_keys + + col_desc = f"{col_name} ({col_type})" + if is_pk: col_desc += " PK" + tables[table_idx]["columns"].append(col_desc) + + schema_lines = [] + for t_info in tables.values(): + cols_str = ", ".join(t_info["columns"]) + schema_lines.append(f"Table {t_info['name']}: [{cols_str}]") + + if foreign_keys: + fk_lines = [] + for src, tgt in foreign_keys: + try: + src_table = column_names[src][0] + src_col = column_names[src][1] + tgt_table = column_names[tgt][0] + tgt_col = column_names[tgt][1] + fk_lines.append(f"FK: {table_names[src_table]}.{src_col} -> {table_names[tgt_table]}.{tgt_col}") + except: pass + if fk_lines: + schema_lines.append("Relationships: " + "; ".join(fk_lines)) + + return "\n".join(schema_lines) + +async def generate_for_db(client, db_entry, semaphore): + async with semaphore: + db_id = db_entry['db_id'] + schema_text = parse_spider_schema(db_entry) + + prompt = USER_TEMPLATE.format(db_id=db_id, schema_text=schema_text, count=30) + + payload = { + "model": AI_MODEL, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": prompt} + ], + "temperature": 0.3, + "max_tokens": 8192 + } + + try: + # 超时时间设长一点,防止生成中断 + response = await client.post(AI_SERVICE_URL, json=payload, headers={"Authorization": f"Bearer {AI_API_KEY}"}, timeout=90) + + # 检查 HTTP 状态码 + if response.status_code != 200: + print(f"Error generating for {db_id}: HTTP {response.status_code} - {response.text[:100]}") + return [] + + # 解析响应 + try: + resp_json = response.json() + content = resp_json['choices'][0]['message']['content'] + except (json.JSONDecodeError, KeyError, IndexError) as e: + # 【新增】详细错误打印,帮助定位问题 + print(f"Error parsing API response for {db_id}: {e}") + print(f"Raw Response: {response.text[:200]}...") # 打印前200个字符看看是什么 + return [] + + # 1. 清洗 Markdown 标记 (保持原有的逻辑) + if "```json" in content: + content = content.split("```json")[1].split("```")[0] + elif "```" in content: + content = content.split("```")[1].split("```")[0] + + # 2. 去除首尾空白 + content = content.strip() + + # === 【新增】修复非法转义字符 === + # 将 JSON 中非法的 \' 替换为合法的 ' + # 注意:使用 r"\'" 表示原始字符串 + content = content.replace(r"\'", "'") + # ============================== + + # 3. 解析 JSON + try: + # strict=False 允许字符串中包含控制字符 (解决 Invalid control character) + data_pairs = json.loads(content, strict=False) + except json.JSONDecodeError as e: + # 如果还是报错,尝试一种更暴力的清洗方式(仅作为后备方案) + print(f"Standard parse failed for {db_id}, trying fallback cleanup...") + try: + # 仅在非结构位置替换(极简处理:假设非法字符只出现在字符串值里) + # 注意:这仍然有风险,但在数据生成脚本中通常够用 + import re + # 移除所有非 JSON 结构的换行符(这很难完美做到,不如直接跳过) + # 建议:如果 strict=False 还是挂了,打印内容人工检查,或者直接让模型重试 + print(f"FAILED CONTENT PREVIEW: {content[:100]}...") + print(f"Raw Content: {content}") + return [] + except: + return [] + + formatted_data = [] + for item in data_pairs: + formatted_data.append({ + "instruction": item['instruction'], + "input": f"Schema:\n{schema_text}", + "output": item['output'], + "db_id": db_id, + "type": "DML_generated" + }) + + return formatted_data + + except Exception as e: + # 打印错误但不中断整个过程 + print(f"Error generating for {db_id}: {e}") + return [] + +async def main(): + if not os.path.exists(SPIDER_TABLES_PATH): + print(f"Error: {SPIDER_TABLES_PATH} not found.") + return + + # 1. 获取训练集白名单 + dev_db_ids = get_dev_db_ids() # 修改为调用上面的新函数 + if not dev_db_ids: + print("Error: No training databases found. Check your train_spider/others paths.") + return + + # 2. 读取所有 Tables + print("Loading Spider tables...") + with open(SPIDER_TABLES_PATH, 'r', encoding='utf-8') as f: + all_tables = json.load(f) + + # 3. 过滤 + # 这一步会把 all_tables 中不属于 dev 集的数据库过滤掉 + target_dbs = [db for db in all_tables if db['db_id'] in dev_db_ids] + + # 4. 并发生成 + results = [] + semaphore = asyncio.Semaphore(CONCURRENCY_LIMIT) + + async with httpx.AsyncClient() as client: + tasks = [generate_for_db(client, db, semaphore) for db in target_dbs] + for f in tqdm(asyncio.as_completed(tasks), total=len(tasks)): + batch = await f + results.extend(batch) + + print(f"Generation complete. Total items: {len(results)}") + + # 5. 保存 + with open(OUTPUT_FILE, 'w', encoding='utf-8') as f: + json.dump(results, f, indent=2, ensure_ascii=False) + + print(f"Saved to {OUTPUT_FILE}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/src/backend/tests/ai/test_html2schema.py b/src/backend/tests/ai/test_html2schema.py new file mode 100644 index 0000000..dbb48fa --- /dev/null +++ b/src/backend/tests/ai/test_html2schema.py @@ -0,0 +1,69 @@ +from bs4 import BeautifulSoup + +def get_schema_design(html_content): + """ + (保持不变) 提取 Logical Design 下的所有纯文本 + """ + if not html_content: + return "" + + soup = BeautifulSoup(html_content, 'html.parser') + extracted_text = [] + + start_node = soup.find(lambda tag: tag.name in ['h1', 'h2', 'h3', 'h4'] and 'Logical Design' in tag.get_text()) + + if not start_node: + return "" + + current = start_node.find_next_sibling() + while current: + if current.name in ['h1', 'h2', 'h3', 'h4']: + break + text = current.get_text(separator='\n', strip=True) + if text: + extracted_text.append(text) + current = current.find_next_sibling() + + return "\n\n".join(extracted_text) + +def get_ddl_statements(html_content): + """ + 提取所有 SQL 代码块,并将 CREATE DATABASE 语句移动到最前方 + """ + if not html_content: + return "" + + soup = BeautifulSoup(html_content, 'html.parser') + ddl_list = [] + + # 1. 先按页面顺序提取所有 SQL 块 + sql_blocks = soup.find_all('code', class_='language-sql') + + for block in sql_blocks: + sql_text = block.get_text().strip() + if sql_text: + ddl_list.append(sql_text) + + # 2. 重新排序:寻找包含 'CREATE DATABASE' 的块 + create_db_index = -1 + for i, sql in enumerate(ddl_list): + # 不区分大小写查找 + if "CREATE DATABASE" in sql.upper(): + create_db_index = i + break + + # 3. 如果找到了,且它不在第一位,就把它移到最前面 + if create_db_index > 0: + create_db_sql = ddl_list.pop(create_db_index) # 拔出来 + ddl_list.insert(0, create_db_sql) # 插到头 + + return "\n\n".join(ddl_list) + +# ================= 测试代码 ================= +if __name__ == "__main__": + import os + if os.path.exists("res.html"): + with open("res.html", "r", encoding="utf-8") as f: + content = f.read() + print("--- DDL (已自动重排序) ---") + print(get_ddl_statements(content)) \ No newline at end of file diff --git a/src/backend/tests/ai/test_mermaid.py b/src/backend/tests/ai/test_mermaid.py new file mode 100644 index 0000000..37e1e17 --- /dev/null +++ b/src/backend/tests/ai/test_mermaid.py @@ -0,0 +1,178 @@ +from openai import OpenAI +import os +import subprocess +import re +from datetime import datetime +import json +# 获取当前脚本所在的绝对路径(Windows Docker 挂载必须用绝对路径) +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +def extract_code_block(text): + # extract mermaid codeblock + pattern = r"```(?:\w*)\n(.*?)```" + match = re.search(pattern, text, re.DOTALL) + if match: + return match.group(1).strip() + return None + +def generate_er(requirement_text): + mapping = {'gpt4': 'gpt-4o-2024-08-06', + 'chatgpt': 'gpt-3.5-turbo', + 'qwen': 'Qwen/Qwen3-32B', + 'deepseek': 'deepseek-v3-241226'} + + # 检查 API Key 是否存在 + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + print("错误: 未找到环境变量 OPENAI_API_KEY") + print("请在 PowerShell 中执行: $env:OPENAI_API_KEY='sk-你的key'") + return None + + client = OpenAI( + api_key=api_key, + base_url="https://ai.nengyongai.cn/v1" + ) + + prompt = """You are a professional database designer. + +Given the following Entity Sets and Relationship Sets, please generate a complete and accurate Mermaid ER diagram code using `erDiagram` syntax. Follow these strict rules: + +1. Use `PK` and `FK` to mark primary and foreign keys in the entity or relationship tables. +2. Use `||--o{`, `||--||`, `o{--o{` etc. to represent correct cardinality: + - `||--o{` means one-to-many + - `||--||` means one-to-one + - `o{--o{` means many-to-many +3. For relationship sets, if needed, create a separate entity-like table to store relationship attributes and foreign keys. +4. Do not include any extra explanation or markdown syntax like ```mermaid. Just return the raw ER diagram code. +5. Use appropriate attribute types like `int`, `string`, `date`, `float`, etc., based on the names. +6. **Attribute Order (CRITICAL)**: + - You MUST follow the format: `Type Name Key`. + - The Key (PK/FK) must ALWAYS be at the **end** of the line. + - **Correct**: `string StudentID PK` + - **WRONG**: `string PK StudentID` (Never put PK/FK before the name) + +7. **Composite Keys (CRITICAL)**: + - If an attribute is **BOTH** a Primary Key and a Foreign Key, you MUST separate them with a **COMMA**. + - **Correct**: `string course_id PK, FK` + - **WRONG**: `string course_id PK FK` (Missing comma causes error) + + """ + + + print("正在调用大模型生成代码...") + try: + chat_completion = client.chat.completions.create( + messages=[ + { + "role": "user", + "content": prompt + "\n\nRequirement:\n" + requirement_text, + } + ], + model=mapping['gpt4'], + ) + except Exception as e: + print(f"调用大模型失败: {e}") + return None + + mermaid_code = extract_code_block(chat_completion.choices[0].message.content) + if mermaid_code is None: + mermaid_code = chat_completion.choices[0].message.content + + + # ========================================== + # 【修改点 1】: 构建嵌入式样式配置 + # ========================================== + # 我们直接把配置写成 Mermaid 的 %%{init: ...}%% 头部 + theme_config = { + "theme": "base", + "themeVariables": { + "primaryColor": "#ffffff", # 表头背景(白色) + "primaryTextColor": "#000000", # 表头文字黑 + "primaryBorderColor": "#3370ff", # 表框边框 + "lineColor": "#3370ff", # 连线 + "tertiaryColor": "#e6f7ff", # 字段行背景(淡蓝) + "tertiaryBorderColor": "#3370ff", # 字段行边框 + "tertiaryTextColor": "#000000", # 字段文字黑 + "mainBkg": "#ffffff", # 整体背景 + "edgeLabelBackground": "#fff" + } +} + # 将字典转为 json 字符串,并拼接到 mermaid 代码的最前面 + init_directive = f"%%{{init: {json.dumps(theme_config)} }}%%\n" + final_content = init_directive + mermaid_code + + # ========================================== + + + # 使用绝对路径处理目录 + directory = os.path.join(BASE_DIR, 'er_data') + if not os.path.exists(directory): + os.makedirs(directory) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + file_name = f"{timestamp}.mmd" + save_file_path = os.path.join(directory, file_name) + + with open(save_file_path, "w", encoding="utf-8") as f: + f.write(final_content) + + print(f"Mermaid code已保存为 {save_file_path}") + + + + # 构造 Windows 兼容的 Docker 命令 + # 注意:Windows 路径包含反斜杠,Docker 挂载需要宿主机绝对路径 + cmd = [ + "docker", "run", "--rm", # --rm: 运行完自动删除容器 + "-v", f"{directory}:/data", # 挂载绝对路径 + "minlag/mermaid-cli", # 使用的镜像 + "-i", f"/data/{file_name}", # 容器内读取路径 + "-o", f"/data/{file_name}.svg" # 容器内输出路径 + ] + + print(f"正在执行 Docker 命令...") + try: + # 执行命令 + result = subprocess.run(cmd, check=True, capture_output=True, text=True) + print("命令执行成功") + # print(result.stdout) + return os.path.join(directory, file_name + '.svg') + except subprocess.CalledProcessError as e: + print("命令执行失败,错误信息:") + print(e.stderr) + return None + +# 测试入口 +if __name__ == "__main__": + requirement_text = """ + CREATE TABLE Student ( + StudentID VARCHAR(50) PRIMARY KEY, + Name VARCHAR(100), + Age INT +); + +CREATE TABLE Course ( + CourseNumber INT PRIMARY KEY, + CourseName VARCHAR(100), + Credits INT, + Lecturer VARCHAR(100), + ClassTime DATETIME +); + +CREATE TABLE Enrollment ( + StudentID VARCHAR(50), + CourseNumber INT, + EnrollmentDate DATETIME, + PRIMARY KEY(StudentID, CourseNumber), + FOREIGN KEY(StudentID) REFERENCES Student(StudentID), + FOREIGN KEY(CourseNumber) REFERENCES Course(CourseNumber) +); + +CREATE TABLE CoursePopularityPrediction ( + CourseNumber INT PRIMARY KEY, + FOREIGN KEY(CourseNumber) REFERENCES Course(CourseNumber) +);""" + + svg_path = generate_er(requirement_text) + if svg_path: + print(f"SVG 生成路径: {svg_path}") \ No newline at end of file diff --git a/src/backend/tests/ai/test_process_and_merge.py b/src/backend/tests/ai/test_process_and_merge.py new file mode 100644 index 0000000..3265d8d --- /dev/null +++ b/src/backend/tests/ai/test_process_and_merge.py @@ -0,0 +1,106 @@ +import json +import os +import random + +# ================= 文件路径配置 ================= +# 请确保这些文件都在你指定的目录下 +SPIDER_TABLES_FILE = "./tables.json" +SPIDER_TRAIN_FILE = "./train_spider.json" +SPIDER_OTHERS_FILE = "./train_others.json" # [新增] README 提到的这部分数据 +GENERATED_DML_FILE = "./spider_dml_extended.json" # 之前生成的增删改数据 +OUTPUT_FILE = "./final_finetune_dataset.json" +# =============================================== + +def parse_schema_map(tables_path): + """预处理 tables.json,生成 db_id -> schema_text 的映射""" + print(f"Loading schemas from {tables_path}...") + with open(tables_path, 'r', encoding='utf-8') as f: + tables_data = json.load(f) + + schema_map = {} + for db in tables_data: + db_id = db['db_id'] + table_names = db['table_names_original'] + column_names = db['column_names_original'] + column_types = db['column_types'] + primary_keys = db['primary_keys'] + + tables_info = {} + for idx, name in enumerate(table_names): + tables_info[idx] = {"name": name, "columns": []} + + for col_idx, (table_idx, col_name) in enumerate(column_names): + if table_idx == -1: continue + col_type = column_types[col_idx] + extra = " PK" if col_idx in primary_keys else "" + tables_info[table_idx]["columns"].append(f"{col_name} ({col_type}){extra}") + + lines = [] + for t_info in tables_info.values(): + col_str = ", ".join(t_info["columns"]) + lines.append(f"Table {t_info['name']}: [{col_str}]") + schema_map[db_id] = "\n".join(lines) + return schema_map + +def process_spider_file(filepath, schema_map, source_tag): + """处理单个 Spider 格式的 json 文件""" + processed = [] + if not os.path.exists(filepath): + print(f"Warning: File {filepath} not found, skipping.") + return processed + + print(f"Processing {source_tag} from {filepath}...") + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + + for item in data: + db_id = item['db_id'] + question = item['question'] + sql = item['query'] + schema_text = schema_map.get(db_id, "") + + processed.append({ + "instruction": question, + "input": f"Schema:\n{schema_text}", + "output": sql, + "source": source_tag, + "db_id": db_id + }) + return processed + +def main(): + if not os.path.exists(SPIDER_TABLES_FILE): + print(f"Error: {SPIDER_TABLES_FILE} not found.") + return + schema_map = parse_schema_map(SPIDER_TABLES_FILE) + + final_dataset = [] + + # 1. 处理 train_spider.json + final_dataset.extend(process_spider_file(SPIDER_TRAIN_FILE, schema_map, "spider_main")) + + # 2. [新增] 处理 train_others.json (根据 README 要求) + final_dataset.extend(process_spider_file(SPIDER_OTHERS_FILE, schema_map, "spider_others")) + + # 3. 合并生成的 DML 数据 (Insert/Update/Delete) + if os.path.exists(GENERATED_DML_FILE): + print(f"Merging generated DML data from {GENERATED_DML_FILE}...") + with open(GENERATED_DML_FILE, 'r', encoding='utf-8') as f: + dml_data = json.load(f) + for item in dml_data: + item["source"] = "generated_dml" + final_dataset.append(item) + else: + print("Warning: Generated DML file not found.") + + # 4. 打乱并保存 + print(f"Shuffling {len(final_dataset)} total examples...") + random.shuffle(final_dataset) + + with open(OUTPUT_FILE, 'w', encoding='utf-8') as f: + json.dump(final_dataset, f, indent=2, ensure_ascii=False) + + print(f"Successfully saved merged dataset to {OUTPUT_FILE}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/backend/tests/ai/test_schema.py b/src/backend/tests/ai/test_schema.py new file mode 100644 index 0000000..908cb71 --- /dev/null +++ b/src/backend/tests/ai/test_schema.py @@ -0,0 +1,13 @@ +from gradio_client import Client + +client = Client("http://43.154.73.48:5000/") + +# 注意:去掉 model_name= 等前缀,直接按顺序传值 +result = client.predict( + "gpt4", # 第1个位置参数 (model_name) + "relation_mcp_test", # 第2个位置参数 (database_name) + "Hello!!", # 第3个位置参数 (requirement_text) + api_name="/run_agent_system" # 这个 api_name 参数必须保留名字 +) + +print(result) \ No newline at end of file diff --git a/src/backend/tests/ai/test_schema_ddl.py b/src/backend/tests/ai/test_schema_ddl.py new file mode 100644 index 0000000..d868026 --- /dev/null +++ b/src/backend/tests/ai/test_schema_ddl.py @@ -0,0 +1,90 @@ +import os +from openai import OpenAI + +# 配置你的 API Key 和 Base URL +# 如果使用 OpenAI 官方:不需要改 base_url +# 如果使用国内模型(如 DeepSeek),base_url 通常是 https://api.deepseek.com/v1 +client = OpenAI( + api_key="sk-YQjmNgkBJqRTsZCsr7r0zkHoLb6G0exL9u8gEkJTf5oZQXmE", # 替换这里 + base_url="http://www.ai678.top:8081/v1" # 根据服务商修改 +) + +def generate_database_schema(requirements: str, db_type: str = "MySQL") -> str: + """ + 根据用户需求和数据库类型生成 DDL。 + + Args: + requirements (str): 用户的功能需求描述。 + db_type (str): "MySQL" 或 "PostgreSQL"。 + + Returns: + str: 生成的 SQL DDL 语句。 + """ + + # 1. 构建 System Prompt (角色设定) + # 我们设定它为资深数据库架构师,强调规范性、范式和特定方言 + system_prompt = f""" + You are a Senior Database Architect. + Your task is to design a database schema based on the user's requirements. + + Target Database Dialect: **{db_type}** + + Rules: + 1. Output strictly valid SQL DDL statements (CREATE TABLE, etc.). + 2. Use appropriate data types for {db_type} (e.g., use SERIAL/UUID for Postgres, AUTO_INCREMENT for MySQL). + 3. Include Primary Keys, Foreign Keys, and NOT NULL constraints where logical. + 4. Add comments to tables and columns (COMMENT ON for Postgres, COMMENT '...' for MySQL). + 5. Follow 3rd Normal Form (3NF) usually, unless performance suggests otherwise. + 6. Wrap the SQL code in a markdown block (```sql ... ```). + 7. After the code, provide a very brief explanation of the design logic. + """ + + # 2. 构建 User Prompt (具体任务) + user_prompt = f""" + Here are the requirements for the system: + "{requirements}" + + Please generate the full DDL script. + """ + + try: + # 3. 调用大模型 + response = client.chat.completions.create( + model="claude-sonnet-4-5-20250929", # 或者 gpt-3.5-turbo, deepseek-chat 等 + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.2, # 较低的温度可以让输出更稳定、严谨 + ) + + return response.choices[0].message.content + + except Exception as e: + return f"Error generating schema: {str(e)}" + +# --- 主程序入口 --- +if __name__ == "__main__": + print("--- 智能数据库 Schema 生成器 ---") + + # 获取输入 + req_input = input("请输入需求描述 (例如: 一个简单的电商系统,包含用户、商品和订单): \n") + if not req_input: + req_input = "一个包含用户、文章和评论的博客系统" + + # 选择数据库类型 + print("\n请选择数据库类型:") + print("1. MySQL") + print("2. PostgreSQL") + choice = input("输入选项 (1/2): ") + + target_db = "MySQL" if choice != "2" else "PostgreSQL" + + print(f"\n正在为 [{target_db}] 生成 Schema,请稍候...\n") + + # 生成并打印 + result = generate_database_schema(req_input, target_db) + + print("-" * 30) + print(result) + print("-" * 30) \ No newline at end of file diff --git a/src/backend/tests/ai/test_text2html.py b/src/backend/tests/ai/test_text2html.py new file mode 100644 index 0000000..0d7d5d8 --- /dev/null +++ b/src/backend/tests/ai/test_text2html.py @@ -0,0 +1,78 @@ +import requests +import json +import random +import string +import os + +# 导入上面的纯提取函数 +from src.backend.tests.ai.test_html2schema import get_schema_design, get_ddl_statements + +def generate_schema(requirements_text): + base_host = "http://43.154.73.48:5000" + session_hash = ''.join(random.choices(string.ascii_lowercase + string.digits, k=11)) + + # 注意:inputs 的顺序绝对不能动 + inputs = [ + "gpt4", # 1. Model + "test_db", # 2. DB Name + requirements_text, # 3. Requirements + "MySQL" # 4. DBMS + ] + + headers = { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + + # 1. 提交任务 + try: + resp = requests.post(f"{base_host}/gradio_api/queue/join", + json={"data": inputs, "session_hash": session_hash, "fn_index": 0}, + headers=headers, timeout=10) + if resp.status_code != 200: return {"status": "error", "message": resp.text} + except Exception as e: return {"status": "error", "message": str(e)} + + # 2. 获取结果 + print("等待处理...", end="", flush=True) + try: + resp = requests.get(f"{base_host}/gradio_api/queue/data?session_hash={session_hash}", + headers=headers, stream=True, timeout=120) + + for line in resp.iter_lines(): + if line: + decoded = line.decode('utf-8') + if decoded.startswith('data: '): + print(".", end="", flush=True) + try: + msg = json.loads(decoded[6:]) + if msg.get('msg') == 'process_completed': + output_data = msg.get('output', {}).get('data', []) + if output_data: + html_content = output_data[0] + + # 保存日志以便排查 + with open("res.html", "w", encoding="utf-8") as f: + f.write(html_content) + + # === 关键点:只提取,不解析 === + # 这里拿到的是一大段纯文本字符串 + raw_schema = get_schema_design(html_content) + raw_ddl = get_ddl_statements(html_content) + + print("\n✅ 获取成功") + return { + "status": "success", + "schema": raw_schema, # 原始文本块 + "ddl": raw_ddl # 原始SQL块 + } + except: continue + except Exception as e: return {"status": "error", "message": str(e)} + + return {"status": "error", "message": "超时"} + +if __name__ == "__main__": + req = "A university needs a student course selection management system to maintain and track students' course selection information. Students have\ninformation such as student ID, name, age, the name of the course chosen by the student, etc. Each student can take multiple courses and can drop or\nchange courses within the specified time. Each course has information such as course number, course name, credits, lecturer and class time. The\npopularity of a course depends on the number of students who take the course. The system can predict the popularity of the course and provide support\nfor academic decision-making" + res = generate_schema(req) + if res['status'] == 'success': + print("\n[Schema Raw Data]\n", res['schema']) + print("\n[DDL Raw Data]\n", res['ddl']) \ No newline at end of file diff --git a/src/backend/tests/ai/test_text_ddl.py b/src/backend/tests/ai/test_text_ddl.py new file mode 100644 index 0000000..5cc8388 --- /dev/null +++ b/src/backend/tests/ai/test_text_ddl.py @@ -0,0 +1,11 @@ +import requests + +url = "https://schema2ddl.strangeloop.fun/generate/ddl" +data = { + "database_requirment": "A university needs a student course selection management system to maintain and track students' course selection information. Students have\ninformation such as student ID, name, age, the name of the course chosen by the student, etc. Each student can take multiple courses and can drop or\nchange courses within the specified time. Each course has information such as course number, course name, credits, lecturer and class time. The\npopularity of a course depends on the number of students who take the course. The system can predict the popularity of the course and provide support\nfor academic decision-making", + "schema": "(1) Student\n- Attribute: student ID, name, age\n- Primary Key: student ID\n\n(2) Course\n- Attribute: course number, course name, credits, lecturer, class time\n- Primary Key: course number\n\n(3) StudentCourses\n- Attribute: student ID, course number\n- Primary Key: student ID, course number\n- Foreign Key: student ID (reference Student: student ID), course number (reference Course: course number)\n ", + "target_db_type": "mysql", + "model": "gpt4" +} +response = requests.post(url, json=data) +print(response.json()) \ No newline at end of file diff --git a/src/backend/tests/config/__init__.py b/src/backend/tests/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/tests/config/test_base.py b/src/backend/tests/config/test_base.py new file mode 100644 index 0000000..02acf22 --- /dev/null +++ b/src/backend/tests/config/test_base.py @@ -0,0 +1,258 @@ +""" +Test cases for configuration modules +""" +import pytest +from pydantic import SecretStr, ValidationError + +from app.config.base import Appconfig, DatabaseConfig, RedisConfig, BaseConfig, LogConfig +from app.core.config import get_config + +class TestAppConfig: + """Test cases for Appconfig class""" + + def test_app_config_creation(self): + """Test creation of Appconfig with valid data""" + config_data = { + "name": "Test App", + "description": "A test application", + "api": "/api/v1", + "host": "0.0.0.0", + "port": 8000, + "uvicorn": "app.main:app", + "version": "1.0.0", + "reload": True + } + + app_config = Appconfig(**config_data) + assert app_config.name == "Test App" + assert app_config.description == "A test application" + assert app_config.api == "/api/v1" + assert app_config.host == "0.0.0.0" + assert app_config.port == 8000 + assert app_config.uvicorn == "app.main:app" + assert app_config.version == "1.0.0" + assert app_config.reload is True + + def test_app_config_missing_required_fields(self): + """Test Appconfig with missing required fields raises ValidationError""" + incomplete_data = { + "name": "Test App", + # Missing other required fields + } + + with pytest.raises(ValidationError): + Appconfig(**incomplete_data) + + +class TestDatabaseConfig: + """Test cases for DatabaseConfig class""" + + def test_database_config_creation(self): + """Test creation of DatabaseConfig with valid data""" + config_data = { + "host": "localhost", + "port": 5432, + "username": "testuser", + "password": "testpassword", + "database": "testdb", + "driver": "postgresql_database+asyncpg", + "echo": True, + "max_overflow": 10, + "pool_size": 50, + "pool_recycle": 3600, + "pool_timeout": 30 + } + + db_config = DatabaseConfig(**config_data) + assert db_config.host == "localhost" + assert db_config.port == 5432 + assert db_config.username == "testuser" + assert isinstance(db_config.password, SecretStr) + assert db_config.database == "testdb" + assert db_config.driver == "postgresql_database+asyncpg" + assert db_config.echo is True + assert db_config.max_overflow == 10 + assert db_config.pool_size == 50 + assert db_config.pool_recycle == 3600 + assert db_config.pool_timeout == 30 + + def test_database_config_sqlalchemy_url(self): + """Test DatabaseConfig sqlalchemy_database_url property""" + config_data = { + "host": "localhost", + "port": 5432, + "username": "testuser", + "password": "testpassword", + "database": "testdb", + "driver": "postgresql_database+asyncpg", + "echo": True, + "max_overflow": 10, + "pool_size": 50, + "pool_recycle": 3600, + "pool_timeout": 30 + } + + db_config = DatabaseConfig(**config_data) + url = db_config.sqlalchemy_database_url + assert url.drivername == "postgresql_database+asyncpg" + assert url.username == "testuser" + assert url.password == "testpassword" + assert url.host == "localhost" + assert url.port == 5432 + assert url.database == "testdb" + + +class TestRedisConfig: + """Test cases for RedisConfig class""" + + def test_redis_config_creation(self): + """Test creation of RedisConfig with valid data""" + config_data = { + "host": "localhost", + "port": 6379, + "username": "redisuser", + "password": "redispassword", + "db": 0 + } + + redis_config = RedisConfig(**config_data) + assert redis_config.host == "localhost" + assert redis_config.port == 6379 + assert redis_config.username == "redisuser" + assert redis_config.password == "redispassword" + assert redis_config.db == 0 + + def test_redis_config_optional_fields(self): + """Test RedisConfig with optional fields""" + config_data = { + "host": "localhost", + "port": 6379, + "username": "", + "password": "", + "db": 0 + } + + redis_config = RedisConfig(**config_data) + assert redis_config.username == "" + assert redis_config.password == "" + + +class TestLogConfig: + """Test cases for LogConfig class""" + + def test_log_config_creation(self): + """Test creation of LogConfig with valid data""" + config_data = { + "file_level": "DEBUG", + "console_level": "INFO", + "retention": "10 days", + "rotation": "500 MB" + } + + log_config = LogConfig(**config_data) + assert log_config.file_level == "DEBUG" + assert log_config.console_level == "INFO" + assert log_config.retention == "10 days" + assert log_config.rotation == "500 MB" + + +class TestBaseConfig: + """Test cases for BaseConfig class""" + + def test_base_config_creation(self): + """Test creation of BaseConfig with valid data""" + app_config_data = { + "name": "Test App", + "description": "A test application", + "api": "/api/v1", + "host": "0.0.0.0", + "port": 8000, + "uvicorn": "app.main:app", + "version": "1.0.0", + "reload": True + } + + db_config_data = { + "host": "localhost", + "port": 5432, + "username": "testuser", + "password": "testpassword", + "database": "testdb", + "driver": "postgresql+asyncpg", + "echo": True, + "max_overflow": 10, + "pool_size": 50, + "pool_recycle": 3600, + "pool_timeout": 30 + } + + redis_config_data = { + "host": "localhost", + "port": 6379, + "username": "redisuser", + "password": "redispassword", + "db": 0 + } + + log_config_data = { + "file_level": "DEBUG", + "console_level": "INFO", + "retention": "10 days", + "rotation": "500 MB" + } + + config_data = { + "app": app_config_data, + "db": db_config_data, + "redis_client": redis_config_data, + "log": log_config_data + } + + base_config = BaseConfig(**config_data) + assert isinstance(base_config.app, Appconfig) + assert isinstance(base_config.db, DatabaseConfig) + assert isinstance(base_config.redis, RedisConfig) + assert isinstance(base_config.log, LogConfig) + + +class TestGetConfig: + """Test cases for get_config function""" + + def test_get_config_default(self): + """Test get_config function with default parameters""" + config = get_config() + assert isinstance(config, BaseConfig) + assert config.app.name == "Auto Database Deployment" + assert config.db.host == "localhost" + assert config.redis.host == "localhost" + assert config.log.file_level == "DEBUG" + + def test_get_config_with_custom_env(self): + """Test get_config function with custom environment""" + # Test with dev environment specifically + config = get_config("config.yaml", "dev") + assert isinstance(config, BaseConfig) + assert config.app.name == "Auto Database Deployment" + assert config.db.host == "localhost" + assert config.redis.host == "localhost" + assert config.log.file_level == "DEBUG" + assert config.log.console_level == "INFO" + assert config.log.retention == "10 days" + assert config.log.rotation == "500 MB" + + # Test with prod environment specifically + config = get_config("config.yaml", "prod") + assert isinstance(config, BaseConfig) + assert config.app.name == "Auto Database Deployment" + assert config.db.host == "localhost" + assert config.redis.host == "localhost" + assert config.log.file_level == "DEBUG" + assert config.log.console_level == "WARN" + assert config.log.retention == "7 days" + assert config.log.rotation == "1 GB" + + def test_get_config_invalid_env(self): + """Test get_config function with invalid environment""" + # This would depend on how you want to handle invalid environments + # For now, we'll skip implementing this test as it depends on your intended behavior + pass \ No newline at end of file diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py new file mode 100644 index 0000000..3890f9d --- /dev/null +++ b/src/backend/tests/conftest.py @@ -0,0 +1,13 @@ +# Configuration file for pytest +import sys +from pathlib import Path + +# Add the app directory to the path so we can import modules +# Get the project root (backend directory) +backend_path = Path(__file__).parent.absolute() +sys.path.insert(0, str(backend_path)) + +# Ensure app module can be imported +app_path = backend_path / "app" +if str(app_path) not in sys.path: + sys.path.insert(0, str(app_path)) \ No newline at end of file diff --git a/src/backend/tests/core/__init__.py b/src/backend/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/tests/core/test_config.py b/src/backend/tests/core/test_config.py new file mode 100644 index 0000000..0d004ab --- /dev/null +++ b/src/backend/tests/core/test_config.py @@ -0,0 +1,242 @@ +import pytest +from unittest.mock import patch, mock_open +import yaml + +from app.core.config import get_config +from app.config.base import BaseConfig + + +class TestGetConfig: + """Test cases for get_config function""" + + def test_get_config_default(self): + """Test get_config with default parameters""" + # Mock the config file content to match the actual config.yaml + mock_yaml_content = { + "env": "dev", + "dev": { + "app": { + "name": "Auto Database Deployment", + "description": "自然语言创建、操作数据库", + "api": "/api/v1", + "host": "0.0.0.0", + "port": 8000, + "uvicorn": "app.server:app", + "version": "1.0.0", + "reload": True + }, + "db": { + "host": "localhost", + "port": 5432, + "username": "admin", + "password": "secure_2025", + "database": "auto_db_deployment", + "driver": "postgresql_database+asyncpg", + "echo": True, + "max_overflow": 10, + "pool_size": 50, + "pool_recycle": 3600, + "pool_timeout": 30 + }, + "redis_client": { + "host": "localhost", + "port": 6379, + "username": "", + "password": "", + "db": 0 + }, + "log": { + "file_level": "DEBUG", + "console_level": "INFO", + "retention": "10 days", + "rotation": "500 MB" + } + } + } + + with patch('app.utils.profile.Profile.get_project_root') as mock_get_project_root, \ + patch('builtins.open', mock_open(read_data=yaml.dump(mock_yaml_content))) as mock_file: + + # Mock project root path + mock_get_project_root.return_value.joinpath.return_value = "config.yaml" + + config = get_config() + + # Verify the returned config is a BaseConfig instance + assert isinstance(config, BaseConfig) + + # Verify some key values + assert config.app.name == "Auto Database Deployment" + assert config.db.host == "localhost" + assert config.redis.host == "localhost" + assert config.log.file_level == "DEBUG" + + def test_get_config_with_custom_env(self): + """Test get_config with custom environment parameter""" + mock_yaml_content = { + "env": "prod", + "dev": { + "app": { + "name": "Auto Database Deployment", + "description": "自然语言创建、操作数据库", + "api": "/api/v1", + "host": "0.0.0.0", + "port": 8000, + "uvicorn": "app.server:app", + "version": "1.0.0", + "reload": True + }, + "db": { + "host": "localhost", + "port": 5432, + "username": "admin", + "password": "secure_2025", + "database": "auto_db_deployment", + "driver": "postgresql_database+asyncpg", + "echo": True, + "max_overflow": 10, + "pool_size": 50, + "pool_recycle": 3600, + "pool_timeout": 30 + }, + "redis_client": { + "host": "localhost", + "port": 6379, + "username": "", + "password": "", + "db": 0 + }, + "log": { + "file_level": "DEBUG", + "console_level": "INFO", + "retention": "10 days", + "rotation": "500 MB" + } + }, + "prod": { + "app": { + "name": "Auto Database Deployment", + "description": "自然语言创建、操作数据库", + "api": "/api/v1", + "host": "0.0.0.0", + "port": 8000, + "uvicorn": "app.main:app", + "version": "1.0.0", + "reload": True + }, + "db": { + "host": "localhost", + "port": 5432, + "username": "admin", + "password": "secure_2025", + "database": "auto_db_deployment", + "driver": "postgresql_database+asyncpg", + "echo": True, + "max_overflow": 10, + "pool_size": 50, + "pool_recycle": 3600, + "pool_timeout": 30 + }, + "redis_client": { + "host": "localhost", + "port": 6379, + "username": "", + "password": "", + "db": 0 + }, + "log": { + "file_level": "DEBUG", + "console_level": "WARN", + "retention": "7 days", + "rotation": "1 GB" + } + } + } + + with patch('app.utils.profile.Profile.get_project_root') as mock_get_project_root, \ + patch('builtins.open', mock_open(read_data=yaml.dump(mock_yaml_content))) as mock_file: + + # Mock project root path + mock_get_project_root.return_value.joinpath.return_value = "config.yaml" + + # Test with prod environment + config = get_config(env="prod") + + # Verify the returned config is a BaseConfig instance + assert isinstance(config, BaseConfig) + + # Verify prod values + assert config.app.name == "Auto Database Deployment" + assert config.app.reload is True + assert config.db.host == "localhost" + assert config.db.echo is True + assert config.redis.host == "localhost" + assert config.log.console_level == "WARN" + + # Test with dev environment + config = get_config(env="dev") + + # Verify dev values + assert config.app.name == "Auto Database Deployment" + assert config.app.reload is True + assert config.db.host == "localhost" + assert config.db.echo is True + assert config.redis.host == "localhost" + assert config.log.console_level == "INFO" + + def test_get_config_lru_cache(self): + """Test that get_config uses lru_cache correctly""" + mock_yaml_content = { + "env": "dev", + "dev": { + "app": { + "name": "Auto Database Deployment", + "description": "自然语言创建、操作数据库", + "api": "/api/v1", + "host": "0.0.0.0", + "port": 8000, + "uvicorn": "app.server:app", + "version": "1.0.0", + "reload": True + }, + "db": { + "host": "localhost", + "port": 5432, + "username": "admin", + "password": "secure_2025", + "database": "auto_db_deployment", + "driver": "postgresql_database+asyncpg", + "echo": True, + "max_overflow": 10, + "pool_size": 50, + "pool_recycle": 3600, + "pool_timeout": 30 + }, + "redis_client": { + "host": "localhost", + "port": 6379, + "username": "", + "password": "", + "db": 0 + }, + "log": { + "file_level": "DEBUG", + "console_level": "INFO", + "retention": "10 days", + "rotation": "500 MB" + } + } + } + + with patch('app.utils.profile.Profile.get_project_root') as mock_get_project_root, \ + patch('builtins.open', mock_open(read_data=yaml.dump(mock_yaml_content))) as mock_file: + + # Mock project root path + mock_get_project_root.return_value.joinpath.return_value = "config.yaml" + + # Call get_config twice + config1 = get_config() + config2 = get_config() + + # With lru_cache, both calls should return the same object + assert config1 is config2 \ No newline at end of file diff --git a/src/backend/tests/core/test_database.py b/src/backend/tests/core/test_database.py new file mode 100644 index 0000000..db28b20 --- /dev/null +++ b/src/backend/tests/core/test_database.py @@ -0,0 +1,119 @@ +import pytest +from unittest.mock import patch, MagicMock + +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.ext.asyncio import AsyncAttrs + +from app.core.database import Base, PsqlHelper +from app.config.base import DatabaseConfig + + +class TestBase: + """Test cases for Base class""" + + def test_base_class_inheritance(self): + """Test that Base class inherits from both AsyncAttrs and DeclarativeBase""" + # Check that Base is a subclass of both AsyncAttrs and DeclarativeBase + assert issubclass(Base, AsyncAttrs) + assert issubclass(Base, DeclarativeBase) + + +class TestPsqlHelper: + """Test cases for PsqlHelper class""" + + @pytest.fixture + def db_config(self): + """Fixture to create a DatabaseConfig instance for testing""" + config_data = { + "host": "localhost", + "port": 5432, + "username": "testuser", + "password": "testpassword", + "database": "testdb", + "driver": "postgresql_database+asyncpg", + "echo": True, + "max_overflow": 10, + "pool_size": 50, + "pool_recycle": 3600, + "pool_timeout": 30 + } + return DatabaseConfig(**config_data) + + def test_get_async_engine(self, db_config): + """Test _get_async_engine method""" + with patch('app.core.database.create_async_engine') as mock_create_engine: + # Mock the return value of create_async_engine + mock_engine = MagicMock(spec=AsyncEngine) + mock_create_engine.return_value = mock_engine + + # Call the method + engine = PsqlHelper._get_async_engine(db_config) + + # Verify create_async_engine was called with correct parameters + mock_create_engine.assert_called_once_with( + url=db_config.sqlalchemy_database_url, + echo=db_config.echo, + pool_recycle=db_config.pool_recycle, + pool_timeout=db_config.pool_timeout, + pool_size=db_config.pool_size, + max_overflow=db_config.max_overflow, + ) + + # Verify the returned engine is the mocked engine + assert engine == mock_engine + + def test_get_async_session(self, db_config): + """Test _get_async_session method""" + with patch('app.core.database.async_sessionmaker') as mock_sessionmaker: + # Create a mock engine + mock_engine = MagicMock(spec=AsyncEngine) + + # Call the method + session = PsqlHelper._get_async_session(mock_engine) + + # Verify async_sessionmaker was called with correct parameters + mock_sessionmaker.assert_called_once_with( + bind=mock_engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, + ) + + def test_get_async_session_with_none_engine(self): + """Test _get_async_session method with None engine""" + with pytest.raises(ValueError, match="Async engine is not initialized"): + PsqlHelper._get_async_session(None) + + def test_init_conn_psql(self, db_config): + """Test init_conn_psql method (synchronous part)""" + with patch('app.core.database.create_async_engine') as mock_create_engine: + # Mock the engine + mock_engine = MagicMock(spec=AsyncEngine) + mock_create_engine.return_value = mock_engine + + # Call the method + engine = PsqlHelper._get_async_engine(db_config) + + # Verify create_async_engine was called + mock_create_engine.assert_called_once_with( + url=db_config.sqlalchemy_database_url, + echo=db_config.echo, + pool_recycle=db_config.pool_recycle, + pool_timeout=db_config.pool_timeout, + pool_size=db_config.pool_size, + max_overflow=db_config.max_overflow, + ) + + # Verify the returned engine is the mocked engine + assert engine == mock_engine + + def test_close_conn_psql(self): + """Test close_conn_psql method""" + # Create a mock engine + mock_engine = MagicMock(spec=AsyncEngine) + mock_engine.dispose = MagicMock() + + # Since we can't easily test the async method, we'll just verify it exists + assert hasattr(PsqlHelper, 'close_conn_psql') + assert callable(PsqlHelper.close_conn_psql) \ No newline at end of file diff --git a/src/backend/tests/core/test_deps.py b/src/backend/tests/core/test_deps.py new file mode 100644 index 0000000..502ff87 --- /dev/null +++ b/src/backend/tests/core/test_deps.py @@ -0,0 +1,49 @@ +import pytest +from unittest.mock import MagicMock + +from fastapi import Request +from sqlalchemy.ext.asyncio import AsyncEngine + +from app.core.deps import get_engin_from_fastapi + + +class TestGetEngineFromFastAPI: + """Test cases for get_engin_from_fastapi function""" + + def test_get_engine_from_fastapi(self): + """Test get_engin_from_fastapi function""" + # Create a mock engine + mock_engine = MagicMock(spec=AsyncEngine) + + # Create a mock request with app.state.psql_engine + mock_request = MagicMock(spec=Request) + mock_request.app.state.psql_engine = mock_engine + + # Call the function + engine = get_engin_from_fastapi(mock_request) + + # Verify the engine is returned correctly + assert engine == mock_engine + assert isinstance(engine, AsyncEngine) + + def test_get_engine_from_fastapi_with_none_engine(self): + """Test get_engin_from_fastapi function with None engine""" + # Create a mock request with None engine + mock_request = MagicMock(spec=Request) + mock_request.app.state.psql_engine = None + + # Call the function + engine = get_engin_from_fastapi(mock_request) + + # Verify None is returned + assert engine is None + + def test_get_engine_from_fastapi_with_missing_state(self): + """Test get_engin_from_fastapi function with missing app state""" + # Create a mock request without proper state + mock_request = MagicMock(spec=Request) + del mock_request.app.state.psql_engine + + # This should raise an AttributeError + with pytest.raises(AttributeError): + get_engin_from_fastapi(mock_request) \ No newline at end of file diff --git a/src/backend/tests/core/test_security.py b/src/backend/tests/core/test_security.py new file mode 100644 index 0000000..48d1325 --- /dev/null +++ b/src/backend/tests/core/test_security.py @@ -0,0 +1,480 @@ +""" +安全模块单元测试。 + +测试 Redis 频率限制、违规日志记录、黑名单管理等功能。 +""" + +import pytest +from datetime import datetime, timezone, timedelta +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.security import ( + RedisFrequencyLimiter, + ViolationLogger, + BlacklistManager +) +from app.redis_client.redis_keys import RedisKeyManager +from app.models.user_account import UserAccount +from app.models.violation_log import ViolationLog + + +# ============================================================================ +# Redis 频率限制器测试 +# ============================================================================ + +class TestRedisFrequencyLimiter: + """测试 RedisFrequencyLimiter 类""" + + def setup_method(self): + """每个测试前初始化""" + self.limiter = RedisFrequencyLimiter() + + def test_frequency_limiter_initializes(self): + """测试频率限制器初始化""" + assert self.limiter is not None + assert self.limiter.redis_client is not None + + def test_first_request_not_exceeded(self): + """测试第一个请求不超限""" + is_exceeded, count = self.limiter.check_frequency( + user_id=9999, # 使用不存在的用户ID避免冲突 + time_window=10, + threshold=20 + ) + assert not is_exceeded + assert count == 1 + + def test_requests_below_threshold(self): + """测试请求数在阈值以下""" + user_id = 9998 + for i in range(1, 20): # 发送19个请求 + is_exceeded, count = self.limiter.check_frequency( + user_id=user_id, + time_window=10, + threshold=20 + ) + assert not is_exceeded + assert count == i + + def test_threshold_boundary(self): + """测试阈值边界 (第20个请求应该正常,第21个超限)""" + user_id = 9997 + + # 第20个请求应该正常 + is_exceeded, count = self.limiter.check_frequency( + user_id=user_id, + time_window=10, + threshold=20 + ) + for _ in range(19): + is_exceeded, count = self.limiter.check_frequency( + user_id=user_id, + time_window=10, + threshold=20 + ) + assert count == 20 + assert not is_exceeded + + # 第21个请求超限 + is_exceeded, count = self.limiter.check_frequency( + user_id=user_id, + time_window=10, + threshold=20 + ) + assert count == 21 + assert is_exceeded + + def test_redis_key_format(self): + """测试 Redis 键格式是否正确""" + user_id = 9996 + self.limiter.check_frequency(user_id=user_id) + + # 验证 Redis 中的键 + if self.limiter.redis_client: + key = RedisKeyManager.get_frequency_limit_key(user_id) + value = self.limiter.redis_client.get(key) + assert value is not None + assert int(value) >= 1 + + def teardown_method(self): + """清理:删除测试数据""" + if self.limiter.redis_client: + for i in range(9996, 10000): + key = RedisKeyManager.get_frequency_limit_key(str(i)) + self.limiter.redis_client.delete(key) + + +# ============================================================================ +# 违规日志记录器测试 +# ============================================================================ + +@pytest.mark.asyncio +class TestViolationLogger: + """测试 ViolationLogger 类""" + + @pytest.fixture + async def test_user(self, db: AsyncSession): + """创建测试用户""" + user = UserAccount( + username=f"test_user_{datetime.now().timestamp()}", + email=f"test_{datetime.now().timestamp()}@example.com", + password_hash="hashed_password" + ) + db.add(user) + await db.flush() + return user + + async def test_log_violation_creates_record(self, db: AsyncSession, test_user): + """测试记录违规日志""" + violation = await ViolationLogger.log_violation( + db=db, + user_id=test_user.user_id, + event_type=ViolationLogger.EVENT_EXCESSIVE_API_USAGE, + event_description="Excessive API usage detected", + risk_level=ViolationLogger.RISK_MEDIUM, + ip_address="192.168.1.1", + client_user_agent="Mozilla/5.0" + ) + + assert violation is not None + assert violation.user_id == test_user.user_id + assert violation.event_type == ViolationLogger.EVENT_EXCESSIVE_API_USAGE + assert violation.resolution_status == "pending" + + async def test_log_multiple_violations(self, db: AsyncSession, test_user): + """测试记录多条违规日志""" + for i in range(3): + violation = await ViolationLogger.log_violation( + db=db, + user_id=test_user.user_id, + event_type=ViolationLogger.EVENT_EXCESSIVE_API_USAGE, + event_description=f"API usage #{i+1}", + risk_level=ViolationLogger.RISK_HIGH, + ip_address="192.168.1.1", + client_user_agent="Mozilla/5.0" + ) + assert violation.violation_id is not None + + async def test_get_violation_count_24h(self, db: AsyncSession, test_user): + """测试获取24小时内的违规记录数""" + # 记录3条新的违规日志 + for i in range(3): + await ViolationLogger.log_violation( + db=db, + user_id=test_user.user_id, + event_type=ViolationLogger.EVENT_EXCESSIVE_API_USAGE, + event_description=f"API usage #{i+1}", + risk_level=ViolationLogger.RISK_LOW, + ip_address="192.168.1.1", + client_user_agent="test" + ) + + # 获取24小时内的计数 + count = await ViolationLogger.get_violation_count( + db=db, + user_id=test_user.user_id, + time_hours=24 + ) + + assert count >= 3 + + async def test_get_violation_count_by_type(self, db: AsyncSession, test_user): + """测试按事件类型筛选的违规记录数""" + # 记录不同类型的违规日志 + await ViolationLogger.log_violation( + db=db, + user_id=test_user.user_id, + event_type=ViolationLogger.EVENT_EXCESSIVE_API_USAGE, + event_description="API usage", + risk_level=ViolationLogger.RISK_MEDIUM, + ip_address="192.168.1.1", + client_user_agent="test" + ) + + await ViolationLogger.log_violation( + db=db, + user_id=test_user.user_id, + event_type=ViolationLogger.EVENT_MULTIPLE_FAILED_LOGINS, + event_description="Failed login", + risk_level=ViolationLogger.RISK_HIGH, + ip_address="192.168.1.1", + client_user_agent="test" + ) + + # 获取特定类型的计数 + count = await ViolationLogger.get_violation_count( + db=db, + user_id=test_user.user_id, + time_hours=24, + event_types=[ViolationLogger.EVENT_EXCESSIVE_API_USAGE] + ) + + assert count >= 1 + + +# ============================================================================ +# 黑名单管理器测试 +# ============================================================================ + +@pytest.mark.asyncio +class TestBlacklistManager: + """测试 BlacklistManager 类""" + + @pytest.fixture + async def test_user(self, db: AsyncSession): + """创建测试用户""" + user = UserAccount( + username=f"ban_test_{datetime.now().timestamp()}", + email=f"ban_{datetime.now().timestamp()}@example.com", + password_hash="hashed_password", + status='normal' + ) + db.add(user) + await db.commit() + return user + + async def test_ban_user_sets_status_banned(self, db: AsyncSession, test_user): + """测试封禁用户设置 status='banned'""" + success = await BlacklistManager.ban_user( + db=db, + user_id=test_user.user_id, + reason="Test ban reason", + banned_by=1 # 管理员ID为1 + ) + + assert success + + # 验证用户状态 + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == test_user.user_id) + ) + user = result.scalar_one() + assert user.status == 'banned' + + async def test_unban_user_sets_status_normal(self, db: AsyncSession, test_user): + """测试解封用户设置 status='normal'""" + # 先封禁 + await BlacklistManager.ban_user( + db=db, + user_id=test_user.user_id, + reason="Test ban" + ) + + # 再解封 + success = await BlacklistManager.unban_user( + db=db, + user_id=test_user.user_id + ) + + assert success + + # 验证用户状态 + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == test_user.user_id) + ) + user = result.scalar_one() + assert user.status == 'normal' + + async def test_auto_suspend_if_needed_with_3_violations(self, db: AsyncSession, test_user): + """测试三击机制:3条违规记录触发自动标记为异常(suspended)""" + # 记录3条违规日志 + for i in range(3): + await ViolationLogger.log_violation( + db=db, + user_id=test_user.user_id, + event_type=ViolationLogger.EVENT_EXCESSIVE_API_USAGE, + event_description=f"Violation #{i+1}", + risk_level=ViolationLogger.RISK_MEDIUM, + ip_address="192.168.1.1", + client_user_agent="test" + ) + + # 触发自动异常标记检查 + is_suspended, reason = await BlacklistManager.auto_suspend_if_needed( + db=db, + user_id=test_user.user_id + ) + + assert is_suspended + assert "3" in reason + + # 验证用户已被标记为异常 + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == test_user.user_id) + ) + user = result.scalar_one() + assert user.status == 'suspended' + + async def test_auto_suspend_if_needed_with_2_violations(self, db: AsyncSession, test_user): + """测试三击机制:2条违规记录不触发自动标记""" + # 记录2条违规日志 + for i in range(2): + await ViolationLogger.log_violation( + db=db, + user_id=test_user.user_id, + event_type=ViolationLogger.EVENT_MULTIPLE_FAILED_LOGINS, + event_description=f"Violation #{i+1}", + risk_level=ViolationLogger.RISK_LOW, + ip_address="192.168.1.1", + client_user_agent="test" + ) + + # 触发自动异常标记检查 + is_suspended, reason = await BlacklistManager.auto_suspend_if_needed( + db=db, + user_id=test_user.user_id + ) + + assert not is_suspended + + # 验证用户未被标记为异常 + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == test_user.user_id) + ) + user = result.scalar_one() + assert user.status == 'normal' + + async def test_auto_ban_ignores_old_violations(self, db: AsyncSession, test_user): + """测试自动封禁只计算24小时内的违规记录""" + # 手动创建一条超过24小时的违规日志 + old_violation = ViolationLog( + user_id=test_user.user_id, + event_type=ViolationLogger.EVENT_EXCESSIVE_API_USAGE, + event_description="Old violation", + risk_level=ViolationLogger.RISK_HIGH, + ip_address="192.168.1.1", + created_at=datetime.now(timezone.utc) - timedelta(hours=25), + resolution_status="pending" + ) + db.add(old_violation) + + # 记录2条新的违规日志(在24小时内) + for i in range(2): + await ViolationLogger.log_violation( + db=db, + user_id=test_user.user_id, + event_type=ViolationLogger.EVENT_FREQUENT_REMOTE_LOGIN, + event_description=f"Recent violation #{i+1}", + risk_level=ViolationLogger.RISK_MEDIUM, + ip_address="192.168.1.1", + client_user_agent="test" + ) + + # 触发自动异常标记检查 + is_suspended, reason = await BlacklistManager.auto_suspend_if_needed( + db=db, + user_id=test_user.user_id + ) + + # 应该不被标记为异常 (只有2杨24小时内的记录) + assert not is_suspended + + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == test_user.user_id) + ) + user = result.scalar_one() + assert user.status == 'normal' + + async def test_get_banned_users_list(self, db: AsyncSession): + """测试获取被封禁用户列表""" + # 创建并封禁一个用户 + ban_user = UserAccount( + username=f"banned_{datetime.now().timestamp()}", + email=f"banned_{datetime.now().timestamp()}@example.com", + password_hash="hashed", + status='banned' + ) + db.add(ban_user) + await db.commit() + + # 获取被封禁用户列表 + banned_users = await BlacklistManager.get_banned_users(db, limit=100) + + assert len(banned_users) > 0 + assert any(u.user_id == ban_user.user_id for u in banned_users) + + +# ============================================================================ +# 集成测试:完整三击流程 +# ============================================================================ + +@pytest.mark.asyncio +class TestStrikeSystemIntegration: + """测试完整的三击自动封禁流程""" + + @pytest.fixture + async def strike_user(self, db: AsyncSession): + """创建测试用户""" + user = UserAccount( + username=f"strike_test_{datetime.now().timestamp()}", + email=f"strike_{datetime.now().timestamp()}@example.com", + password_hash="hashed", + status='normal' + ) + db.add(user) + await db.commit() + return user + + async def test_complete_strike_system_flow(self, db: AsyncSession, strike_user): + """ + 测试完整的三击流程: + 1. 记录第1条违规日志 → 用户仍然正常 + 2. 记录第2条违规日志 → 用户仍然正常 + 3. 记录第3条违规日志 → 自动触发标记为异常(suspended) + 4. 用户被标记为 suspended 状态 + """ + + # 步骤1:第一条违规 + await ViolationLogger.log_violation( + db=db, + user_id=strike_user.user_id, + event_type=ViolationLogger.EVENT_EXCESSIVE_API_USAGE, + event_description="Strike 1", + risk_level=ViolationLogger.RISK_MEDIUM, + ip_address="192.168.1.1", + client_user_agent="test" + ) + + is_suspended, _ = await BlacklistManager.auto_suspend_if_needed(db, strike_user.user_id) + assert not is_suspended + + # 步骤2:第二条违规 + await ViolationLogger.log_violation( + db=db, + user_id=strike_user.user_id, + event_type=ViolationLogger.EVENT_MULTIPLE_FAILED_LOGINS, + event_description="Strike 2", + risk_level=ViolationLogger.RISK_HIGH, + ip_address="192.168.1.1", + client_user_agent="test" + ) + + is_suspended, _ = await BlacklistManager.auto_suspend_if_needed(db, strike_user.user_id) + assert not is_suspended + + # 步骤3:第三条违规 → 自动标记为异常 + await ViolationLogger.log_violation( + db=db, + user_id=strike_user.user_id, + event_type=ViolationLogger.EVENT_FREQUENT_REMOTE_LOGIN, + event_description="Strike 3", + risk_level=ViolationLogger.RISK_CRITICAL, + ip_address="192.168.1.1", + client_user_agent="test" + ) + + is_suspended, reason = await BlacklistManager.auto_suspend_if_needed(db, strike_user.user_id) + assert is_suspended + assert "3" in reason + + # 验证最终状态 + result = await db.execute( + select(UserAccount).where(UserAccount.user_id == strike_user.user_id) + ) + user = result.scalar_one() + assert user.status == 'suspended' + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/backend/tests/models/__init__.py b/src/backend/tests/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/tests/models/test_ai_generated_statement.py b/src/backend/tests/models/test_ai_generated_statement.py new file mode 100644 index 0000000..0ef9cea --- /dev/null +++ b/src/backend/tests/models/test_ai_generated_statement.py @@ -0,0 +1,109 @@ +import pytest +from sqlalchemy import Column, Text, Integer, Index, CheckConstraint, ForeignKey, DateTime +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.sql import func + +from app.models.ai_generated_statement import AIGeneratedStatement +from app.core.database import Base + + +class TestAIGeneratedStatementModel: + """Test cases for AIGeneratedStatement model""" + + def test_ai_generated_statement_inherits_from_base(self): + """Test that AIGeneratedStatement inherits from Base""" + assert issubclass(AIGeneratedStatement, Base) + + def test_ai_generated_statement_table_name(self): + """Test that AIGeneratedStatement has the correct table name""" + assert AIGeneratedStatement.__tablename__ == 'ai_generated_statement' + + def test_ai_generated_statement_table_args(self): + """Test that AIGeneratedStatement has the correct table args""" + table_args = AIGeneratedStatement.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 2 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 2 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 1 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_ai_generated_statement_columns(self): + """Test that AIGeneratedStatement has all the expected columns with correct types and properties""" + columns = AIGeneratedStatement.__table__.columns + + # Check statement_id column + assert 'statement_id' in columns + statement_id_col = columns['statement_id'] + assert isinstance(statement_id_col.type, Integer) + assert statement_id_col.primary_key is True + assert statement_id_col.autoincrement is True + assert statement_id_col.index is True + assert statement_id_col.comment == '语句ID' + + # Check message_id column + assert 'message_id' in columns + message_id_col = columns['message_id'] + assert isinstance(message_id_col.type, Integer) + assert message_id_col.nullable is False + assert message_id_col.comment == '所属消息' + # Check that it has foreign key (without checking specific table) + assert len(message_id_col.foreign_keys) >= 1 + + # Check statement_order column + assert 'statement_order' in columns + statement_order_col = columns['statement_order'] + assert isinstance(statement_order_col.type, Integer) + assert statement_order_col.nullable is False + assert statement_order_col.comment == '语句执行顺序' + + # Check sql_text column + assert 'sql_text' in columns + sql_text_col = columns['sql_text'] + assert isinstance(sql_text_col.type, Text) + assert sql_text_col.nullable is False + assert sql_text_col.comment == 'SQL语句文本' + + # Check statement_type column + assert 'statement_type' in columns + statement_type_col = columns['statement_type'] + assert isinstance(statement_type_col.type, Text) + assert statement_type_col.nullable is False + assert statement_type_col.comment == '语句类型' + + # Check execution_status column + assert 'execution_status' in columns + execution_status_col = columns['execution_status'] + assert isinstance(execution_status_col.type, Text) + assert execution_status_col.default.arg == 'pending' + assert execution_status_col.nullable is False + assert execution_status_col.comment == '执行状态' + + # Check execution_result column + assert 'execution_result' in columns + execution_result_col = columns['execution_result'] + assert isinstance(execution_result_col.type, JSONB) + assert execution_result_col.comment == '执行结果' + + # Check executed_at column + assert 'executed_at' in columns + executed_at_col = columns['executed_at'] + assert isinstance(executed_at_col.type, DateTime) + assert executed_at_col.comment == '执行时间' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.comment == '创建时间' diff --git a/src/backend/tests/models/test_database_instance.py b/src/backend/tests/models/test_database_instance.py new file mode 100644 index 0000000..f1ebc19 --- /dev/null +++ b/src/backend/tests/models/test_database_instance.py @@ -0,0 +1,117 @@ +import pytest +from sqlalchemy import Column, Integer, String, Text, DateTime, CheckConstraint, Index +from sqlalchemy.sql import func + +from app.models.database_instance import DatabaseInstance +from app.core.database import Base + + +class TestDatabaseInstanceModel: + """Test cases for DatabaseInstance model""" + + def test_database_instance_inherits_from_base(self): + """Test that DatabaseInstance inherits from Base""" + assert issubclass(DatabaseInstance, Base) + + def test_database_instance_table_name(self): + """Test that DatabaseInstance has the correct table name""" + assert DatabaseInstance.__tablename__ == 'database_instance' + + def test_database_instance_table_args(self): + """Test that DatabaseInstance has the correct table args""" + table_args = DatabaseInstance.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 3 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 1 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 2 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_database_instance_columns(self): + """Test that DatabaseInstance has all the expected columns with correct types and properties""" + columns = DatabaseInstance.__table__.columns + + # Check instance_id column + assert 'instance_id' in columns + instance_id_col = columns['instance_id'] + assert isinstance(instance_id_col.type, Integer) + assert instance_id_col.primary_key is True + assert instance_id_col.autoincrement is True + assert instance_id_col.comment == '实例ID' + + # Check db_type column + assert 'db_type' in columns + db_type_col = columns['db_type'] + assert isinstance(db_type_col.type, Text) + assert db_type_col.nullable is False + assert db_type_col.default.arg == 'mysql' + assert db_type_col.comment == '数据库类型' + + # Check db_host column + assert 'db_host' in columns + db_host_col = columns['db_host'] + assert isinstance(db_host_col.type, String) + assert db_host_col.nullable is False + assert db_host_col.comment == '数据库主机地址' + + # Check db_port column + assert 'db_port' in columns + db_port_col = columns['db_port'] + assert isinstance(db_port_col.type, Integer) + assert db_port_col.default.arg == 3306 + assert db_port_col.comment == '端口' + + # Check db_name column + assert 'db_name' in columns + db_name_col = columns['db_name'] + assert isinstance(db_name_col.type, String) + assert db_name_col.nullable is False + assert db_name_col.comment == '数据库名' + + # Check db_username column + assert 'db_username' in columns + db_username_col = columns['db_username'] + assert isinstance(db_username_col.type, String) + assert db_username_col.nullable is False + assert db_username_col.comment == '用户名' + + # Check db_password column + assert 'db_password' in columns + db_password_col = columns['db_password'] + assert isinstance(db_password_col.type, String) + assert db_password_col.nullable is False + assert db_password_col.comment == '密码(加密存储)' + + # Check status column + assert 'status' in columns + status_col = columns['status'] + assert isinstance(status_col.type, Text) + assert status_col.default.arg == 'active' + assert status_col.nullable is False + assert status_col.comment == '实例状态' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.comment == '创建时间' + + # Check last_used_at column + assert 'last_used_at' in columns + last_used_at_col = columns['last_used_at'] + assert isinstance(last_used_at_col.type, DateTime) + assert last_used_at_col.server_default is not None + assert last_used_at_col.onupdate is not None + assert last_used_at_col.nullable is True + assert last_used_at_col.comment == '最后使用时间' diff --git a/src/backend/tests/models/test_domain_knowledge.py b/src/backend/tests/models/test_domain_knowledge.py new file mode 100644 index 0000000..6bf3c37 --- /dev/null +++ b/src/backend/tests/models/test_domain_knowledge.py @@ -0,0 +1,82 @@ +import pytest +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index +from sqlalchemy.sql import func + +from app.models.domain_knowledge import DomainKnowledge +from app.core.database import Base + + +class TestDomainKnowledgeModel: + """Test cases for DomainKnowledge model""" + + def test_domain_knowledge_inherits_from_base(self): + """Test that DomainKnowledge inherits from Base""" + assert issubclass(DomainKnowledge, Base) + + def test_domain_knowledge_table_name(self): + """Test that DomainKnowledge has the correct table name""" + assert DomainKnowledge.__tablename__ == 'domain_knowledge' + + def test_domain_knowledge_table_args(self): + """Test that DomainKnowledge has the correct table args""" + table_args = DomainKnowledge.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 2 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 2 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_domain_knowledge_columns(self): + """Test that DomainKnowledge has all the expected columns with correct types and properties""" + columns = DomainKnowledge.__table__.columns + + # Check knowledge_id column + assert 'knowledge_id' in columns + knowledge_id_col = columns['knowledge_id'] + assert isinstance(knowledge_id_col.type, Integer) + assert knowledge_id_col.primary_key is True + assert knowledge_id_col.autoincrement is True + assert knowledge_id_col.comment == '知识条目ID' + + # Check project_id column + assert 'project_id' in columns + project_id_col = columns['project_id'] + assert isinstance(project_id_col.type, Integer) + assert project_id_col.nullable is False + assert project_id_col.comment == '所属项目' + # Check that it has foreign key (without checking specific table) + assert len(project_id_col.foreign_keys) >= 1 + + # Check term column + assert 'term' in columns + term_col = columns['term'] + assert isinstance(term_col.type, String) + assert term_col.nullable is False + assert term_col.comment == '业务术语' + + # Check definition column + assert 'definition' in columns + definition_col = columns['definition'] + assert isinstance(definition_col.type, Text) + assert definition_col.nullable is False + assert definition_col.comment == '术语定义' + + # Check examples column + assert 'examples' in columns + examples_col = columns['examples'] + assert isinstance(examples_col.type, Text) + assert examples_col.comment == '使用示例' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.comment == '创建时间' diff --git a/src/backend/tests/models/test_message.py b/src/backend/tests/models/test_message.py new file mode 100644 index 0000000..adce38b --- /dev/null +++ b/src/backend/tests/models/test_message.py @@ -0,0 +1,93 @@ +import pytest +from sqlalchemy import Column, Integer, Text, Boolean, CheckConstraint, Index, DateTime, ForeignKey + +from app.models.message import Message +from app.core.database import Base + + +class TestMessageModel: + """Test cases for Message model""" + + def test_message_inherits_from_base(self): + """Test that Message inherits from Base""" + assert issubclass(Message, Base) + + def test_message_table_name(self): + """Test that Message has the correct table name""" + assert Message.__tablename__ == 'message' + + def test_message_table_args(self): + """Test that Message has the correct table args""" + table_args = Message.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 3 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 1 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 2 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_message_columns(self): + """Test that Message has all the expected columns with correct types and properties""" + columns = Message.__table__.columns + + # Check message_id column + assert 'message_id' in columns + message_id_col = columns['message_id'] + assert isinstance(message_id_col.type, Integer) + assert message_id_col.primary_key is True + assert message_id_col.autoincrement is True + assert message_id_col.comment == '消息ID' + + # Check session_id column + assert 'session_id' in columns + session_id_col = columns['session_id'] + assert isinstance(session_id_col.type, Integer) + assert session_id_col.nullable is False + assert session_id_col.comment == '所属会话' + # Check that it has foreign key (without checking specific table) + assert len(session_id_col.foreign_keys) >= 1 + + # Check message_type column + assert 'message_type' in columns + message_type_col = columns['message_type'] + assert isinstance(message_type_col.type, Text) + assert message_type_col.nullable is False + assert message_type_col.comment == '消息类型:user/assistant/system' + + # Check content column + assert 'content' in columns + content_col = columns['content'] + assert isinstance(content_col.type, Text) + assert content_col.nullable is False + assert content_col.comment == '原始文本内容' + + # Check requires_confirmation column + assert 'requires_confirmation' in columns + requires_confirmation_col = columns['requires_confirmation'] + assert isinstance(requires_confirmation_col.type, Boolean) + assert requires_confirmation_col.default.arg is False + assert requires_confirmation_col.comment == '是否需要用户确认' + + # Check user_confirmed column + assert 'user_confirmed' in columns + user_confirmed_col = columns['user_confirmed'] + assert isinstance(user_confirmed_col.type, Boolean) + assert user_confirmed_col.default.arg is False + assert user_confirmed_col.comment == '用户是否确认执行' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.comment == '创建时间' \ No newline at end of file diff --git a/src/backend/tests/models/test_operation_log.py b/src/backend/tests/models/test_operation_log.py new file mode 100644 index 0000000..41255b8 --- /dev/null +++ b/src/backend/tests/models/test_operation_log.py @@ -0,0 +1,164 @@ +import pytest +from sqlalchemy import Column, Integer, Text, String, Boolean, CheckConstraint, DateTime, ForeignKey, Index +from sqlalchemy.sql import func + +from app.models.operation_log import OperationLog +from app.core.database import Base + + +class TestOperationLogModel: + """Test cases for OperationLog model""" + + def test_operation_log_inherits_from_base(self): + """Test that OperationLog inherits from Base""" + assert issubclass(OperationLog, Base) + + def test_operation_log_table_name(self): + """Test that OperationLog has the correct table name""" + assert OperationLog.__tablename__ == 'operation_log' + + def test_operation_log_table_args(self): + """Test that OperationLog has the correct table args""" + table_args = OperationLog.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 4 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 2 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 3 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_operation_log_columns(self): + """Test that OperationLog has all the expected columns with correct types and properties""" + columns = OperationLog.__table__.columns + + # Check log_id column + assert 'log_id' in columns + log_id_col = columns['log_id'] + assert isinstance(log_id_col.type, Integer) + assert log_id_col.primary_key is True + assert log_id_col.autoincrement is True + assert log_id_col.comment == '日志ID' + + # Check user_id column + assert 'user_id' in columns + user_id_col = columns['user_id'] + assert isinstance(user_id_col.type, Integer) + assert user_id_col.nullable is False + assert user_id_col.comment == '操作用户' + # Check that it has foreign key (without checking specific table) + assert len(user_id_col.foreign_keys) >= 1 + + # Check project_id column + assert 'project_id' in columns + project_id_col = columns['project_id'] + assert isinstance(project_id_col.type, Integer) + assert project_id_col.nullable is False + assert project_id_col.comment == '所属项目' + # Check that it has foreign key (without checking specific table) + assert len(project_id_col.foreign_keys) >= 1 + + # Check session_id column + assert 'session_id' in columns + session_id_col = columns['session_id'] + assert isinstance(session_id_col.type, Integer) + assert session_id_col.nullable is False + assert session_id_col.comment == '所属会话' + # Check that it has foreign key (without checking specific table) + assert len(session_id_col.foreign_keys) >= 1 + + # Check message_id column + assert 'message_id' in columns + message_id_col = columns['message_id'] + assert isinstance(message_id_col.type, Integer) + assert message_id_col.nullable is False + assert message_id_col.comment == '触发操作的消息' + # Check that it has foreign key (without checking specific table) + assert len(message_id_col.foreign_keys) >= 1 + + # Check statement_id column + assert 'statement_id' in columns + statement_id_col = columns['statement_id'] + assert isinstance(statement_id_col.type, Integer) + assert statement_id_col.nullable is False + assert statement_id_col.comment == '关联的SQL语句' + # Check that it has foreign key (without checking specific table) + assert len(statement_id_col.foreign_keys) >= 1 + + # Check operation_type column + assert 'operation_type' in columns + operation_type_col = columns['operation_type'] + assert isinstance(operation_type_col.type, Text) + assert operation_type_col.nullable is False + assert operation_type_col.comment == '操作类型' + + # Check target_table column + assert 'target_table' in columns + target_table_col = columns['target_table'] + assert isinstance(target_table_col.type, String) + assert target_table_col.nullable is False + assert target_table_col.comment == '目标表名' + + # Check sql_statement column + assert 'sql_statement' in columns + sql_statement_col = columns['sql_statement'] + assert isinstance(sql_statement_col.type, Text) + assert sql_statement_col.nullable is False + assert sql_statement_col.comment == '完整SQL语句' + + # Check natural_language_intent column + assert 'natural_language_intent' in columns + natural_language_intent_col = columns['natural_language_intent'] + assert isinstance(natural_language_intent_col.type, Text) + assert natural_language_intent_col.nullable is False + assert natural_language_intent_col.comment == '用户原始指令' + + # Check status column + assert 'status' in columns + status_col = columns['status'] + assert isinstance(status_col.type, Text) + assert status_col.nullable is False + assert status_col.comment == '执行状态' + + # Check error_message column + assert 'error_message' in columns + error_message_col = columns['error_message'] + assert isinstance(error_message_col.type, Text) + assert error_message_col.nullable is True + assert error_message_col.comment == '错误信息(若失败)' + + # Check affected_rows column + assert 'affected_rows' in columns + affected_rows_col = columns['affected_rows'] + assert isinstance(affected_rows_col.type, Integer) + assert affected_rows_col.default.arg == 0 + assert affected_rows_col.comment == '影响行数' + + # Check confirmed_by_user column + assert 'confirmed_by_user' in columns + confirmed_by_user_col = columns['confirmed_by_user'] + assert isinstance(confirmed_by_user_col.type, Boolean) + assert confirmed_by_user_col.default.arg is False + assert confirmed_by_user_col.comment == '是否经用户确认' + + # Check executed_at column + assert 'executed_at' in columns + executed_at_col = columns['executed_at'] + assert isinstance(executed_at_col.type, DateTime) + assert executed_at_col.server_default is not None + assert executed_at_col.comment == '执行时间' + + # Check execution_duration_ms column + assert 'execution_duration_ms' in columns + execution_duration_ms_col = columns['execution_duration_ms'] + assert isinstance(execution_duration_ms_col.type, Integer) + assert execution_duration_ms_col.comment == '执行耗时(毫秒)' diff --git a/src/backend/tests/models/test_project.py b/src/backend/tests/models/test_project.py new file mode 100644 index 0000000..134c468 --- /dev/null +++ b/src/backend/tests/models/test_project.py @@ -0,0 +1,110 @@ +import pytest +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, CheckConstraint, Index +from sqlalchemy.dialects.postgresql import JSONB + +from app.models.project import Project +from app.core.database import Base + + +class TestProjectModel: + """Test cases for Project model""" + + def test_project_inherits_from_base(self): + """Test that Project inherits from Base""" + assert issubclass(Project, Base) + + def test_project_table_name(self): + """Test that Project has the correct table name""" + assert Project.__tablename__ == 'project' + + def test_project_table_args(self): + """Test that Project has the correct table args""" + table_args = Project.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 3 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 1 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 3 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_project_columns(self): + """Test that Project has all the expected columns with correct types and properties""" + columns = Project.__table__.columns + + # Check project_id column + assert 'project_id' in columns + project_id_col = columns['project_id'] + assert isinstance(project_id_col.type, Integer) + assert project_id_col.primary_key is True + assert project_id_col.autoincrement is True + assert project_id_col.comment == '项目ID' + + # Check user_id column + assert 'user_id' in columns + user_id_col = columns['user_id'] + assert isinstance(user_id_col.type, Integer) + assert user_id_col.nullable is False + assert user_id_col.comment == '所属用户' + # Check that it has foreign key (without checking specific table) + assert len(user_id_col.foreign_keys) >= 1 + + # Check instance_id column + assert 'instance_id' in columns + instance_id_col = columns['instance_id'] + assert isinstance(instance_id_col.type, Integer) + assert instance_id_col.nullable is False + assert instance_id_col.comment == '关联的数据库实例' + # Check that it has foreign key (without checking specific table) + assert len(instance_id_col.foreign_keys) >= 1 + + # Check project_name column + assert 'project_name' in columns + project_name_col = columns['project_name'] + assert isinstance(project_name_col.type, String) + assert project_name_col.nullable is False + assert project_name_col.comment == '项目名称,如"我的服装店"' + + # Check description column + assert 'description' in columns + description_col = columns['description'] + assert isinstance(description_col.type, Text) + assert description_col.comment == '业务需求描述' + + # Check schema_definition column + assert 'schema_definition' in columns + schema_definition_col = columns['schema_definition'] + assert isinstance(schema_definition_col.type, JSONB) + assert schema_definition_col.comment == 'AI生成的DDL结构(表、字段、约束等)' + + # Check project_status column + assert 'project_status' in columns + project_status_col = columns['project_status'] + assert isinstance(project_status_col.type, Text) + assert project_status_col.default.arg == 'active' + assert project_status_col.nullable is False + assert project_status_col.comment == '项目状态' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.comment == '创建时间' + + # Check updated_at column + assert 'updated_at' in columns + updated_at_col = columns['updated_at'] + assert isinstance(updated_at_col.type, DateTime) + assert updated_at_col.server_default is not None + assert updated_at_col.onupdate is not None + assert updated_at_col.comment == '最后更新时间' \ No newline at end of file diff --git a/src/backend/tests/models/test_query_result.py b/src/backend/tests/models/test_query_result.py new file mode 100644 index 0000000..97a26da --- /dev/null +++ b/src/backend/tests/models/test_query_result.py @@ -0,0 +1,81 @@ +import pytest +from sqlalchemy import Column, Integer, Text, String, DateTime, ForeignKey, Index +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.sql import func + +from app.models.query_result import QueryResult +from app.core.database import Base + + +class TestQueryResultModel: + """Test cases for QueryResult model""" + + def test_query_result_inherits_from_base(self): + """Test that QueryResult inherits from Base""" + assert issubclass(QueryResult, Base) + + def test_query_result_table_name(self): + """Test that QueryResult has the correct table name""" + assert QueryResult.__tablename__ == 'query_result' + + def test_query_result_table_args(self): + """Test that QueryResult has the correct table args""" + table_args = QueryResult.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 2 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 2 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_query_result_columns(self): + """Test that QueryResult has all the expected columns with correct types and properties""" + columns = QueryResult.__table__.columns + + # Check result_id column + assert 'result_id' in columns + result_id_col = columns['result_id'] + assert isinstance(result_id_col.type, Integer) + assert result_id_col.primary_key is True + assert result_id_col.autoincrement is True + assert result_id_col.comment == '结果ID' + + # Check statement_id column + assert 'statement_id' in columns + statement_id_col = columns['statement_id'] + assert isinstance(statement_id_col.type, Integer) + assert statement_id_col.nullable is False + assert statement_id_col.comment == '关联的SQL语句ID' + # Check that it has foreign key (without checking specific table) + assert len(statement_id_col.foreign_keys) >= 1 + + # Check result_data column + assert 'result_data' in columns + result_data_col = columns['result_data'] + assert isinstance(result_data_col.type, JSONB) + assert result_data_col.comment == '查询结果数据(快照)' + + # Check data_summary column + assert 'data_summary' in columns + data_summary_col = columns['data_summary'] + assert isinstance(data_summary_col.type, Text) + assert data_summary_col.comment == 'AI生成的自然语言摘要' + + # Check chart_type column + assert 'chart_type' in columns + chart_type_col = columns['chart_type'] + assert isinstance(chart_type_col.type, String) + assert chart_type_col.comment == '推荐图表类型(bar, line, pie等)' + + # Check cached_at column + assert 'cached_at' in columns + cached_at_col = columns['cached_at'] + assert isinstance(cached_at_col.type, DateTime) + assert cached_at_col.server_default is not None + assert cached_at_col.comment == '缓存时间' diff --git a/src/backend/tests/models/test_session.py b/src/backend/tests/models/test_session.py new file mode 100644 index 0000000..b60f40e --- /dev/null +++ b/src/backend/tests/models/test_session.py @@ -0,0 +1,77 @@ +import pytest +from sqlalchemy import Column, String, CheckConstraint, Index, Integer, ForeignKey, DateTime +from sqlalchemy.sql import func +from app.models.session import Session +from app.core.database import Base + +class TestSessionModel: + """Test cases for Session model""" + + def test_session_inherits_from_base(self): + """Test that Session inherits from Base""" + assert issubclass(Session, Base) + + def test_session_table_name(self): + """Test that Session has the correct table name""" + assert Session.__tablename__ == 'session' + + def test_session_table_args(self): + """Test that Session has the correct table args""" + table_args = Session.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have indexes (exact count may vary) + assert len(table_args) >= 2 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 2 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_session_columns(self): + """Test that Session has all the expected columns with correct types and properties""" + columns = Session.__table__.columns + + # Check session_id column + assert 'session_id' in columns + session_id_col = columns['session_id'] + assert isinstance(session_id_col.type, Integer) + assert session_id_col.autoincrement is True + assert session_id_col.primary_key is True + assert session_id_col.comment == '会话ID' + + # Check project_id column + assert 'project_id' in columns + project_id_col = columns['project_id'] + assert isinstance(project_id_col.type, Integer) + assert project_id_col.nullable is False + assert project_id_col.comment == '关联的项目ID' + # Check that it has foreign key (without checking specific table) + assert len(project_id_col.foreign_keys) >= 1 + + # Check session_name column + assert 'session_name' in columns + session_name_col = columns['session_name'] + assert isinstance(session_name_col.type, String) + assert session_name_col.nullable is False + assert session_name_col.default.arg == 'New Session' + assert session_name_col.comment == '会话名称' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.comment == '创建时间' + + # Check last_activity column + assert 'last_activity' in columns + last_activity_col = columns['last_activity'] + assert isinstance(last_activity_col.type, DateTime) + assert last_activity_col.server_default is not None + assert last_activity_col.onupdate is not None + assert last_activity_col.nullable is True + assert last_activity_col.comment == '最后活动时间' diff --git a/src/backend/tests/models/test_system_announcement.py b/src/backend/tests/models/test_system_announcement.py new file mode 100644 index 0000000..e79c3d6 --- /dev/null +++ b/src/backend/tests/models/test_system_announcement.py @@ -0,0 +1,95 @@ +import pytest +from sqlalchemy import Column, Text, String, Integer, Index, CheckConstraint, ForeignKey, DateTime +from sqlalchemy.sql import func +from app.models.system_announcement import SystemAnnouncement +from app.core.database import Base + +class TestSystemAnnouncementModel: + """Test cases for SystemAnnouncement model""" + + def test_system_announcement_inherits_from_base(self): + """Test that SystemAnnouncement inherits from Base""" + assert issubclass(SystemAnnouncement, Base) + + def test_system_announcement_table_name(self): + """Test that SystemAnnouncement has the correct table name""" + assert SystemAnnouncement.__tablename__ == 'system_announcement' + + def test_system_announcement_table_args(self): + """Test that SystemAnnouncement has the correct table args""" + table_args = SystemAnnouncement.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 3 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 1 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 1 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_system_announcement_columns(self): + """Test that SystemAnnouncement has all the expected columns with correct types and properties""" + columns = SystemAnnouncement.__table__.columns + + # Check announcement_id column + assert 'announcement_id' in columns + announcement_id_col = columns['announcement_id'] + assert isinstance(announcement_id_col.type, Integer) + assert announcement_id_col.autoincrement is True + assert announcement_id_col.primary_key is True + assert announcement_id_col.comment == '公告唯一ID' + + # Check title column + assert 'title' in columns + title_col = columns['title'] + assert isinstance(title_col.type, String) + assert title_col.nullable is False + assert title_col.comment == '公告标题' + + # Check content column + assert 'content' in columns + content_col = columns['content'] + assert isinstance(content_col.type, Text) + assert content_col.nullable is False + assert content_col.comment == '公告内容' + + # Check status column + assert 'status' in columns + status_col = columns['status'] + assert isinstance(status_col.type, String) + assert status_col.default.arg == 'draft' + assert status_col.nullable is False + assert status_col.comment == '公告状态:draft(草稿)、published(已发布)、unpublished(已下架)、expired(已过期)' + + # Check created_by column + assert 'created_by' in columns + created_by_col = columns['created_by'] + assert isinstance(created_by_col.type, Integer) + assert created_by_col.nullable is False + assert created_by_col.comment == '创建人(管理员user_id)' + # Check that it has foreign key (without checking specific table) + assert len(created_by_col.foreign_keys) >= 1 + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.comment == '公告发布时间' + + # Check updated_at column + assert 'updated_at' in columns + updated_at_col = columns['updated_at'] + assert isinstance(updated_at_col.type, DateTime) + assert updated_at_col.server_default is not None + assert updated_at_col.onupdate is not None + assert updated_at_col.nullable is True + assert updated_at_col.comment == '最后更新时间' diff --git a/src/backend/tests/models/test_unban_request.py b/src/backend/tests/models/test_unban_request.py new file mode 100644 index 0000000..e30a2a9 --- /dev/null +++ b/src/backend/tests/models/test_unban_request.py @@ -0,0 +1,124 @@ +import pytest +from sqlalchemy import Column, Integer, DateTime, Text, ForeignKey, CheckConstraint, Index +from sqlalchemy.sql import func + +from app.models.unban_request import UnbanRequest +from app.core.database import Base + +class TestUnbanRequestModel: + """Test cases for UnbanRequest model""" + + def test_unban_request_inherits_from_base(self): + """Test that UnbanRequest inherits from Base""" + assert issubclass(UnbanRequest, Base) + + def test_unban_request_table_name(self): + """Test that UnbanRequest has the correct table name""" + assert UnbanRequest.__tablename__ == 'unban_request' + + def test_unban_request_table_args(self): + """Test that UnbanRequest has the correct table args""" + table_args = UnbanRequest.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 3 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 1 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 2 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_unban_request_columns(self): + """Test that UnbanRequest has all the expected columns with correct types and properties""" + columns = UnbanRequest.__table__.columns + + # Check request_id column + assert 'request_id' in columns + request_id_col = columns['request_id'] + assert isinstance(request_id_col.type, Integer) + assert request_id_col.autoincrement is True + assert request_id_col.primary_key is True + assert request_id_col.comment == '申请ID' + + # Check user_id column + assert 'user_id' in columns + user_id_col = columns['user_id'] + assert isinstance(user_id_col.type, Integer) + assert user_id_col.nullable is False + assert user_id_col.comment == '申请用户ID' + # Check that it has foreign key (without checking specific table) + assert len(user_id_col.foreign_keys) >= 1 + + # Check ban_log_id column + assert 'ban_log_id' in columns + ban_log_id_col = columns['ban_log_id'] + assert isinstance(ban_log_id_col.type, Integer) + assert ban_log_id_col.nullable is False + assert ban_log_id_col.comment == '关联的封禁日志ID' + # Check that it has foreign key (without checking specific table) + assert len(ban_log_id_col.foreign_keys) >= 1 + + # Check request_time column + assert 'request_time' in columns + request_time_col = columns['request_time'] + assert isinstance(request_time_col.type, DateTime) + assert request_time_col.server_default is not None + assert request_time_col.nullable is False + assert request_time_col.comment == '申请时间' + + # Check reason column + assert 'reason' in columns + reason_col = columns['reason'] + assert isinstance(reason_col.type, Text) + assert reason_col.nullable is False + assert reason_col.comment == '申请解封理由' + + # Check supporting_evidence column + assert 'supporting_evidence' in columns + supporting_evidence_col = columns['supporting_evidence'] + assert isinstance(supporting_evidence_col.type, Text) + assert supporting_evidence_col.comment == '支持证据' + + # Check status column + assert 'status' in columns + status_col = columns['status'] + assert isinstance(status_col.type, Text) + assert status_col.default.arg == 'pending' + assert status_col.nullable is False + assert status_col.comment == '申请状态:pending/approved/rejected/processed' + + # Check admin_user_id column + assert 'admin_user_id' in columns + admin_user_id_col = columns['admin_user_id'] + assert isinstance(admin_user_id_col.type, Integer) + assert admin_user_id_col.comment == '处理管理员ID' + # Check that it has foreign key (without checking specific table) + assert len(admin_user_id_col.foreign_keys) >= 0 # Should be 1, but nullable + + # Check decision_time column + assert 'decision_time' in columns + decision_time_col = columns['decision_time'] + assert isinstance(decision_time_col.type, DateTime) + assert decision_time_col.comment == '处理时间' + + # Check decision_reason column + assert 'decision_reason' in columns + decision_reason_col = columns['decision_reason'] + assert isinstance(decision_reason_col.type, Text) + assert decision_reason_col.comment == '处理结果' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.nullable is False + assert created_at_col.comment == '记录创建时间' diff --git a/src/backend/tests/models/test_user_account.py b/src/backend/tests/models/test_user_account.py new file mode 100644 index 0000000..6cf76a9 --- /dev/null +++ b/src/backend/tests/models/test_user_account.py @@ -0,0 +1,126 @@ +import pytest +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, CheckConstraint, Index +from sqlalchemy.sql import func + +from app.models.user_account import UserAccount +from app.core.database import Base + + +class TestUserAccountModel: + """Test cases for UserAccount model""" + + def test_user_account_inherits_from_base(self): + """Test that UserAccount inherits from Base""" + assert issubclass(UserAccount, Base) + + def test_user_account_table_name(self): + """Test that UserAccount has the correct table name""" + assert UserAccount.__tablename__ == 'user_account' + + def test_user_account_table_args(self): + """Test that UserAccount has the correct table args""" + table_args = UserAccount.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 3 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 1 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 2 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_user_account_columns(self): + """Test that UserAccount has all the expected columns with correct types and properties""" + columns = UserAccount.__table__.columns + + # Check user_id column + assert 'user_id' in columns + user_id_col = columns['user_id'] + assert isinstance(user_id_col.type, Integer) + assert user_id_col.primary_key is True + assert user_id_col.autoincrement is True + assert user_id_col.index is True + assert user_id_col.comment == '用户唯一ID' + + # Check username column + assert 'username' in columns + username_col = columns['username'] + assert isinstance(username_col.type, String) + assert username_col.unique is True + assert username_col.index is True + assert username_col.nullable is False + assert username_col.comment == '用户名' + + # Check email column + assert 'email' in columns + email_col = columns['email'] + assert isinstance(email_col.type, String) + assert email_col.unique is True + assert email_col.index is True + assert email_col.nullable is False + assert email_col.comment == '注册邮箱' + + # Check password_hash column + assert 'password_hash' in columns + password_hash_col = columns['password_hash'] + assert isinstance(password_hash_col.type, String) + assert password_hash_col.nullable is False + assert password_hash_col.comment == '加密后的密码' + + # Check status column + assert 'status' in columns + status_col = columns['status'] + assert isinstance(status_col.type, Text) + assert status_col.default.arg == 'normal' + assert status_col.nullable is False + assert status_col.comment == '账户状态:normal/suspended/banned' + + # Check used_databases column + assert 'used_databases' in columns + used_databases_col = columns['used_databases'] + assert isinstance(used_databases_col.type, Integer) + assert used_databases_col.default.arg == 0 + assert used_databases_col.nullable is False + assert used_databases_col.comment == '已使用的数据库项目数' + + # Check avatar_url column + assert 'avatar_url' in columns + avatar_url_col = columns['avatar_url'] + assert isinstance(avatar_url_col.type, Text) + assert avatar_url_col.comment == '头像图片URL' + + # Check is_admin column + assert 'is_admin' in columns + is_admin_col = columns['is_admin'] + assert isinstance(is_admin_col.type, Boolean) + assert is_admin_col.default.arg is False + assert is_admin_col.comment == '是否为管理员' + + # Check max_databases column + assert 'max_databases' in columns + max_databases_col = columns['max_databases'] + assert isinstance(max_databases_col.type, Integer) + assert max_databases_col.default.arg == 10 + assert max_databases_col.comment == '允许创建的最大数据库项目数' + + # Check last_login_at column + assert 'last_login_at' in columns + last_login_at_col = columns['last_login_at'] + assert isinstance(last_login_at_col.type, DateTime) + assert last_login_at_col.server_default is not None + assert last_login_at_col.comment == '最后登录时间' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.comment == '注册时间' \ No newline at end of file diff --git a/src/backend/tests/models/test_user_ban_log.py b/src/backend/tests/models/test_user_ban_log.py new file mode 100644 index 0000000..b69dd28 --- /dev/null +++ b/src/backend/tests/models/test_user_ban_log.py @@ -0,0 +1,112 @@ +import pytest +from sqlalchemy import Column, Integer, Text, CheckConstraint, DateTime, ForeignKey, Index +from sqlalchemy.sql import func +from sqlalchemy.dialects.postgresql import INTERVAL + +from app.models.user_ban_log import UserBanLog +from app.core.database import Base + +class TestUserBanLogModel: + """Test cases for UserBanLog model""" + + def test_user_ban_log_inherits_from_base(self): + """Test that UserBanLog inherits from Base""" + assert issubclass(UserBanLog, Base) + + def test_user_ban_log_table_name(self): + """Test that UserBanLog has the correct table name""" + assert UserBanLog.__tablename__ == 'user_ban_log' + + def test_user_ban_log_table_args(self): + """Test that UserBanLog has the correct table args""" + table_args = UserBanLog.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 3 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 1 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 2 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_user_ban_log_columns(self): + """Test that UserBanLog has all the expected columns with correct types and properties""" + columns = UserBanLog.__table__.columns + + # Check log_id column + assert 'log_id' in columns + log_id_col = columns['log_id'] + assert isinstance(log_id_col.type, Integer) + assert log_id_col.autoincrement is True + assert log_id_col.primary_key is True + assert log_id_col.comment == "日志ID" + + # Check target_user_id column + assert 'target_user_id' in columns + target_user_id_col = columns['target_user_id'] + assert isinstance(target_user_id_col.type, Integer) + assert target_user_id_col.nullable is False + assert target_user_id_col.comment == "被封禁的用户ID" + # Check that it has foreign key (without checking specific table) + assert len(target_user_id_col.foreign_keys) >= 1 + + # Check admin_user_id column + assert 'admin_user_id' in columns + admin_user_id_col = columns['admin_user_id'] + assert isinstance(admin_user_id_col.type, Integer) + assert admin_user_id_col.nullable is False + assert admin_user_id_col.comment == "执行操作的管理员ID" + # Check that it has foreign key (without checking specific table) + assert len(admin_user_id_col.foreign_keys) >= 1 + + # Check action_type column + assert 'action_type' in columns + action_type_col = columns['action_type'] + assert isinstance(action_type_col.type, Text) + assert action_type_col.nullable is False + assert action_type_col.comment == "操作类型:ban/unban/warning" + + # Check reason column + assert 'reason' in columns + reason_col = columns['reason'] + assert isinstance(reason_col.type, Text) + assert reason_col.nullable is False + assert reason_col.comment == "封禁/解封原因" + + # Check duration column + assert 'duration' in columns + duration_col = columns['duration'] + assert isinstance(duration_col.type, INTERVAL) + assert duration_col.nullable is True + assert duration_col.comment == "封禁持续时间" + + # Check effective_time column + assert 'effective_time' in columns + effective_time_col = columns['effective_time'] + assert isinstance(effective_time_col.type, DateTime) + assert effective_time_col.server_default is not None + assert effective_time_col.nullable is False + assert effective_time_col.comment == "生效时间" + + # Check expiry_time column + assert 'expiry_time' in columns + expiry_time_col = columns['expiry_time'] + assert isinstance(expiry_time_col.type, DateTime) + assert expiry_time_col.nullable is True + assert expiry_time_col.comment == "过期时间" + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.nullable is False + assert created_at_col.comment == "记录创建时间" diff --git a/src/backend/tests/models/test_user_login_history.py b/src/backend/tests/models/test_user_login_history.py new file mode 100644 index 0000000..fc1678d --- /dev/null +++ b/src/backend/tests/models/test_user_login_history.py @@ -0,0 +1,120 @@ +import pytest +from sqlalchemy import Column, Text, String, Integer, Boolean, Index, CheckConstraint, ForeignKey, DateTime +from sqlalchemy.dialects.postgresql import INTERVAL, JSONB +from sqlalchemy.sql import func +from app.models.user_login_history import UserLoginHistory +from app.core.database import Base + +class TestUserLoginHistoryModel: + """Test cases for UserLoginHistory model""" + + def test_user_login_history_inherits_from_base(self): + """Test that UserLoginHistory inherits from Base""" + assert issubclass(UserLoginHistory, Base) + + def test_user_login_history_table_name(self): + """Test that UserLoginHistory has the correct table name""" + assert UserLoginHistory.__tablename__ == 'user_login_history' + + def test_user_login_history_table_args(self): + """Test that UserLoginHistory has the correct table args""" + table_args = UserLoginHistory.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 4 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 1 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 3 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_user_login_history_columns(self): + """Test that UserLoginHistory has all the expected columns with correct types and properties""" + columns = UserLoginHistory.__table__.columns + + # Check login_id column + assert 'login_id' in columns + login_id_col = columns['login_id'] + assert isinstance(login_id_col.type, Integer) + assert login_id_col.autoincrement is True + assert login_id_col.primary_key is True + assert login_id_col.comment == '登录记录ID' + + # Check user_id column + assert 'user_id' in columns + user_id_col = columns['user_id'] + assert isinstance(user_id_col.type, Integer) + assert user_id_col.nullable is False + assert user_id_col.comment == '用户ID' + # Check that it has foreign key (without checking specific table) + assert len(user_id_col.foreign_keys) >= 1 + + # Check login_time column + assert 'login_time' in columns + login_time_col = columns['login_time'] + assert isinstance(login_time_col.type, DateTime) + assert login_time_col.server_default is not None + assert login_time_col.nullable is False + assert login_time_col.comment == '登录时间' + + # Check logout_time column + assert 'logout_time' in columns + logout_time_col = columns['logout_time'] + assert isinstance(logout_time_col.type, DateTime) + assert logout_time_col.nullable is True + assert logout_time_col.comment == '登出时间' + + # Check session_duration column + assert 'session_duration' in columns + session_duration_col = columns['session_duration'] + assert isinstance(session_duration_col.type, INTERVAL) + assert session_duration_col.nullable is True + assert session_duration_col.comment == '会话持续时间' + + # Check ip_address column + assert 'ip_address' in columns + ip_address_col = columns['ip_address'] + assert isinstance(ip_address_col.type, String) + assert ip_address_col.nullable is False + assert ip_address_col.comment == '登录IP地址' + + # Check user_agent column + assert 'user_agent' in columns + user_agent_col = columns['user_agent'] + assert isinstance(user_agent_col.type, Text) + assert user_agent_col.comment == '浏览器/设备信息' + + # Check login_status column + assert 'login_status' in columns + login_status_col = columns['login_status'] + assert isinstance(login_status_col.type, Text) + assert login_status_col.nullable is False + assert login_status_col.comment == '登录状态:success/failed/expired/forced_logout' + + # Check failure_reason column + assert 'failure_reason' in columns + failure_reason_col = columns['failure_reason'] + assert isinstance(failure_reason_col.type, Text) + assert failure_reason_col.comment == '失败原因(仅当失败时)' + + # Check device_info column + assert 'device_info' in columns + device_info_col = columns['device_info'] + assert isinstance(device_info_col.type, JSONB) + assert device_info_col.comment == '详细设备信息(JSON格式)' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.nullable is False + assert created_at_col.comment == '记录创建时间' diff --git a/src/backend/tests/models/test_user_profile_change_log.py b/src/backend/tests/models/test_user_profile_change_log.py new file mode 100644 index 0000000..4ecddb9 --- /dev/null +++ b/src/backend/tests/models/test_user_profile_change_log.py @@ -0,0 +1,101 @@ +import pytest +from sqlalchemy import Column, Integer, CheckConstraint, Text, DateTime, String, ForeignKey, Index +from sqlalchemy.sql import func + +from app.models.user_profile_change_log import UserProfileChangeLog +from app.core.database import Base + +class TestUserProfileChangeLogModel: + """Test cases for UserProfileChangeLog model""" + + def test_user_profile_change_log_inherits_from_base(self): + """Test that UserProfileChangeLog inherits from Base""" + assert issubclass(UserProfileChangeLog, Base) + + def test_user_profile_change_log_table_name(self): + """Test that UserProfileChangeLog has the correct table name""" + assert UserProfileChangeLog.__tablename__ == 'user_profile_change_log' + + def test_user_profile_change_log_table_args(self): + """Test that UserProfileChangeLog has the correct table args""" + table_args = UserProfileChangeLog.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 3 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 1 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 3 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_user_profile_change_log_columns(self): + """Test that UserProfileChangeLog has all the expected columns with correct types and properties""" + columns = UserProfileChangeLog.__table__.columns + + # Check change_id column + assert 'change_id' in columns + change_id_col = columns['change_id'] + assert isinstance(change_id_col.type, Integer) + assert change_id_col.autoincrement is True + assert change_id_col.primary_key is True + assert change_id_col.comment == '变更ID' + + # Check user_id column + assert 'user_id' in columns + user_id_col = columns['user_id'] + assert isinstance(user_id_col.type, Integer) + assert user_id_col.nullable is False + assert user_id_col.comment == '被修改的用户ID' + # Check that it has foreign key (without checking specific table) + assert len(user_id_col.foreign_keys) >= 1 + + # Check change_type column + assert 'change_type' in columns + change_type_col = columns['change_type'] + assert isinstance(change_type_col.type, Text) + assert change_type_col.nullable is False + assert change_type_col.comment == '变更类型' + + # Check old_value column + assert 'old_value' in columns + old_value_col = columns['old_value'] + assert isinstance(old_value_col.type, Text) + assert old_value_col.nullable is True + assert old_value_col.comment == '变更前的值' + + # Check new_value column + assert 'new_value' in columns + new_value_col = columns['new_value'] + assert isinstance(new_value_col.type, Text) + assert new_value_col.nullable is False + assert new_value_col.comment == '变更后的值' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.nullable is False + assert created_at_col.comment == '变更时间' + + # Check ip_address column + assert 'ip_address' in columns + ip_address_col = columns['ip_address'] + assert isinstance(ip_address_col.type, String) + assert ip_address_col.nullable is False + assert ip_address_col.comment == '操作IP地址' + + # Check user_agent column + assert 'user_agent' in columns + user_agent_col = columns['user_agent'] + assert isinstance(user_agent_col.type, Text) + assert user_agent_col.nullable is False + assert user_agent_col.comment == '操作设备信息' diff --git a/src/backend/tests/models/test_violation_log.py b/src/backend/tests/models/test_violation_log.py new file mode 100644 index 0000000..a1c0668 --- /dev/null +++ b/src/backend/tests/models/test_violation_log.py @@ -0,0 +1,132 @@ +import pytest +from sqlalchemy import Column, Text, String, Integer, DateTime, CheckConstraint, Index, ForeignKey +from sqlalchemy.sql import func + +from app.models.violation_log import ViolationLog +from app.core.database import Base + +class TestViolationLogModel: + """Test cases for ViolationLog model""" + + def test_violation_log_inherits_from_base(self): + """Test that ViolationLog inherits from Base""" + assert issubclass(ViolationLog, Base) + + def test_violation_log_table_name(self): + """Test that ViolationLog has the correct table name""" + assert ViolationLog.__tablename__ == 'violation_log' + + def test_violation_log_table_args(self): + """Test that ViolationLog has the correct table args""" + table_args = ViolationLog.__table_args__ + assert isinstance(table_args, tuple) + # Check that we have constraints and indexes (exact count may vary) + assert len(table_args) >= 4 + + # Check constraints + constraints = [arg for arg in table_args if isinstance(arg, CheckConstraint)] + assert len(constraints) >= 1 + + # Check indexes + indexes = [arg for arg in table_args if isinstance(arg, Index)] + assert len(indexes) >= 4 + + # Check comment dict + comment_dict = [arg for arg in table_args if isinstance(arg, dict)] + assert len(comment_dict) == 1 + assert 'comment' in comment_dict[0] + + def test_violation_log_columns(self): + """Test that ViolationLog has all the expected columns with correct types and properties""" + columns = ViolationLog.__table__.columns + + # Check violation_id column + assert 'violation_id' in columns + violation_id_col = columns['violation_id'] + assert isinstance(violation_id_col.type, Integer) + assert violation_id_col.autoincrement is True + assert violation_id_col.primary_key is True + assert violation_id_col.comment == '违规行为ID' + + # Check user_id column + assert 'user_id' in columns + user_id_col = columns['user_id'] + assert isinstance(user_id_col.type, Integer) + assert user_id_col.nullable is False + assert user_id_col.comment == '违规用户ID' + # Check that it has foreign key (without checking specific table) + assert len(user_id_col.foreign_keys) >= 1 + + # Check event_type column + assert 'event_type' in columns + event_type_col = columns['event_type'] + assert isinstance(event_type_col.type, Text) + assert event_type_col.nullable is False + assert event_type_col.comment == '违规事件类型' + + # Check event_description column + assert 'event_description' in columns + event_description_col = columns['event_description'] + assert isinstance(event_description_col.type, Text) + assert event_description_col.nullable is False + assert event_description_col.comment == '事件详细描述' + + # Check risk_level column + assert 'risk_level' in columns + risk_level_col = columns['risk_level'] + assert isinstance(risk_level_col.type, Text) + assert risk_level_col.nullable is False + assert risk_level_col.comment == '风险等级:LOW/MEDIUM/HIGH/CRITICAL' + + # Check ip_address column + assert 'ip_address' in columns + ip_address_col = columns['ip_address'] + assert isinstance(ip_address_col.type, String) + assert ip_address_col.nullable is False + assert ip_address_col.comment == '违规操作IP地址' + + # Check client_user_agent column + assert 'client_user_agent' in columns + client_user_agent_col = columns['client_user_agent'] + assert isinstance(client_user_agent_col.type, Text) + assert client_user_agent_col.nullable is True + assert client_user_agent_col.comment == '客户端用户代理信息' + + # Check request_content column + assert 'request_content' in columns + request_content_col = columns['request_content'] + assert isinstance(request_content_col.type, Text) + assert request_content_col.nullable is True + assert request_content_col.comment == '原始请求内容' + + # Check handled_by column + assert 'handled_by' in columns + handled_by_col = columns['handled_by'] + assert isinstance(handled_by_col.type, Integer) + assert handled_by_col.nullable is True + assert handled_by_col.comment == '处理人ID' + # Check that it has foreign key (without checking specific table) + assert len(handled_by_col.foreign_keys) >= 0 # Should be 1, but nullable + + # Check handled_at column + assert 'handled_at' in columns + handled_at_col = columns['handled_at'] + assert isinstance(handled_at_col.type, DateTime) + assert handled_at_col.nullable is True + assert handled_at_col.comment == '处理时间' + + # Check resolution_status column + assert 'resolution_status' in columns + resolution_status_col = columns['resolution_status'] + assert isinstance(resolution_status_col.type, Text) + assert resolution_status_col.default.arg == 'pending' + assert resolution_status_col.nullable is False + assert resolution_status_col.comment == '处理状态:pending/resolved/rejected' + + # Check created_at column + assert 'created_at' in columns + created_at_col = columns['created_at'] + assert isinstance(created_at_col.type, DateTime) + assert created_at_col.server_default is not None + assert created_at_col.nullable is False + assert created_at_col.comment == '记录创建时间' diff --git a/src/backend/tests/mysql_database/__init__.py b/src/backend/tests/mysql_database/__init__.py new file mode 100644 index 0000000..fe038e2 --- /dev/null +++ b/src/backend/tests/mysql_database/__init__.py @@ -0,0 +1 @@ +"""Tests for MySQL database functionality.""" \ No newline at end of file diff --git a/src/backend/tests/mysql_database/test_mysql_database.py b/src/backend/tests/mysql_database/test_mysql_database.py new file mode 100644 index 0000000..e01105c --- /dev/null +++ b/src/backend/tests/mysql_database/test_mysql_database.py @@ -0,0 +1,186 @@ +import pytest +from unittest.mock import patch, MagicMock, AsyncMock + +from app.mysql.mysql_database import MysqlHelper +from app.core.exceptions import InvalidOperationException + + +class TestMysqlDatabase: + """Test cases for mysql_database.py""" + + @patch('app.mysql.mysql_database.create_async_engine') + @patch('app.mysql.mysql_database.MySQLConfig') + @pytest.mark.asyncio + async def test_init_root_engine(self, mock_mysql_config, mock_create_engine): + """Test initializing root engine""" + # Setup mocks + mock_engine = AsyncMock() + mock_create_engine.return_value = mock_engine + mock_config = MagicMock() + mock_config.plugin = "mysql_native_password" + mock_config.ssl = False + mock_config.sqlalchemy_database_url = "mysql+aiomysql://root:password@localhost:3306" + mock_config.pool_size = 2 + mock_config.pool_recycle = 3600 + mock_config.pool_timeout = 30 + mock_config.max_overflow = 0 + + # Run the method + await MysqlHelper.init_root_engine(mock_config) + + # Assertions + mock_create_engine.assert_called_once_with( + mock_config.sqlalchemy_database_url, + connect_args={ + "auth_plugin": mock_config.plugin, + "ssl": mock_config.ssl + }, + pool_size=mock_config.pool_size, + pool_recycle=mock_config.pool_recycle, + pool_timeout=mock_config.pool_timeout, + max_overflow=mock_config.max_overflow + ) + assert MysqlHelper._root_engine == mock_engine + + @patch('app.mysql.mysql_database.create_async_engine') + @patch('app.mysql.mysql_database.MySQLConfig') + @patch('app.mysql.mysql_database.DatabaseInstance') + @pytest.mark.asyncio + async def test_init_user_engine(self, mock_database_instance, mock_mysql_config, mock_create_engine): + """Test initializing user engine""" + # Setup mocks + mock_engine = AsyncMock() + mock_create_engine.return_value = mock_engine + mock_config = MagicMock() + mock_config.plugin = "mysql_native_password" + mock_config.ssl = False + mock_config.pool_size = 5 + mock_config.pool_recycle = 3600 + mock_config.pool_timeout = 30 + mock_config.max_overflow = 10 + + mock_instance = MagicMock() + mock_instance.instance_id = 1 + mock_instance.user_database_url = "mysql+aiomysql://user:password@localhost:3306/db" + + # Run the method + await MysqlHelper.init_user_engine(mock_config, mock_instance) + + # Assertions + mock_create_engine.assert_called_once_with( + mock_instance.user_database_url, + connect_args={ + "auth_plugin": mock_config.plugin, + "ssl": mock_config.ssl + }, + pool_size=mock_config.pool_size, + pool_recycle=mock_config.pool_recycle, + pool_timeout=mock_config.pool_timeout, + max_overflow=mock_config.max_overflow + ) + assert MysqlHelper._user_engine[mock_instance.instance_id] == mock_engine + + @pytest.mark.asyncio + async def test_get_root_engine_success(self): + """Test getting root engine when initialized""" + # Setup + mock_engine = AsyncMock() + MysqlHelper._root_engine = mock_engine + + # Run the method + result = await MysqlHelper.get_root_engine() + + # Assertions + assert result == mock_engine + + @pytest.mark.asyncio + async def test_get_root_engine_failure(self): + """Test getting root engine when not initialized""" + # Setup + MysqlHelper._root_engine = None + + # Run the method and check exception + with pytest.raises(InvalidOperationException): + await MysqlHelper.get_root_engine() + + @patch('app.mysql.mysql_database.DatabaseInstance') + @pytest.mark.asyncio + async def test_get_user_engine_success(self, mock_database_instance): + """Test getting user engine when initialized""" + # Setup + mock_engine = AsyncMock() + mock_instance = MagicMock() + mock_instance.instance_id = 1 + MysqlHelper._user_engine = {mock_instance.instance_id: mock_engine} + + # Run the method + result = await MysqlHelper.get_user_engine(mock_instance) + + # Assertions + assert result == mock_engine + + @patch('app.mysql.mysql_database.DatabaseInstance') + @pytest.mark.asyncio + async def test_get_user_engine_failure(self, mock_database_instance): + """Test getting user engine when not initialized""" + # Setup + mock_instance = MagicMock() + mock_instance.instance_id = 1 + MysqlHelper._user_engine = {} + + # Run the method and check exception + with pytest.raises(InvalidOperationException): + await MysqlHelper.get_user_engine(mock_instance) + + @pytest.mark.asyncio + async def test_close_root_engine(self): + """Test closing root engine""" + # Setup + mock_engine = AsyncMock() + mock_engine.dispose = AsyncMock() + MysqlHelper._root_engine = mock_engine + + # Run the method + await MysqlHelper.close_root_engine() + + # Assertions + mock_engine.dispose.assert_called_once() + assert MysqlHelper._root_engine is None + + @pytest.mark.asyncio + @patch('app.mysql.mysql_database.DatabaseInstance') + async def test_close_user_engine(self, mock_database_instance): + """Test closing user engine""" + # Setup + mock_engine = AsyncMock() + mock_engine.dispose = AsyncMock() + mock_instance = MagicMock() + mock_instance.instance_id = 1 + MysqlHelper._user_engine = {mock_instance.instance_id: mock_engine} + + # Run the method + await MysqlHelper.close_user_engine(mock_instance) + + # Assertions + mock_engine.dispose.assert_called_once() + assert mock_instance.instance_id not in MysqlHelper._user_engine + + @pytest.mark.asyncio + async def test_close_all_engine(self): + """Test closing all engines""" + # Setup + mock_root_engine = AsyncMock() + mock_root_engine.dispose = AsyncMock() + MysqlHelper._root_engine = mock_root_engine + + mock_user_engine = AsyncMock() + mock_user_engine.dispose = AsyncMock() + MysqlHelper._user_engine = {1: mock_user_engine} + + # Run the method + await MysqlHelper.close_all_engine() + + # Assertions + mock_root_engine.dispose.assert_called_once() + mock_user_engine.dispose.assert_called_once() + assert MysqlHelper._root_engine is None \ No newline at end of file diff --git a/src/backend/tests/mysql_database/test_mysql_execute.py b/src/backend/tests/mysql_database/test_mysql_execute.py new file mode 100644 index 0000000..fb13988 --- /dev/null +++ b/src/backend/tests/mysql_database/test_mysql_execute.py @@ -0,0 +1,125 @@ +import pytest +from unittest.mock import patch, MagicMock, AsyncMock + +from app.mysql.mysql_execute import execute_sql_root, execute_dql_user, execute_dml_user +from app.core.exceptions import SQLSecurityException + + +class TestMysqlExecute: + """Test cases for mysql_execute.py""" + + @patch('app.mysql.mysql_execute.validate_safe_sql') + @patch('app.mysql.mysql_execute.MysqlHelper') + @pytest.mark.asyncio + async def test_execute_sql_root(self, mock_mysql_helper, mock_validate_safe_sql): + """Test executing SQL as root user""" + # Setup mocks + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_mysql_helper.get_root_engine = AsyncMock(return_value=mock_engine) + mock_connection.execute = AsyncMock() + + sql = "CREATE DATABASE test_db" + + # Run the method + await execute_sql_root(sql) + + # Assertions + mock_validate_safe_sql.assert_called_once_with(sql, is_root=True) + mock_mysql_helper.get_root_engine.assert_called_once() + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + + @patch('app.mysql.mysql_execute.validate_safe_sql') + @patch('app.mysql.mysql_execute.MysqlHelper') + @patch('app.mysql.mysql_execute.DatabaseInstance') + @pytest.mark.asyncio + async def test_execute_dql_user(self, mock_database_instance, mock_mysql_helper, mock_validate_safe_sql): + """Test executing DQL as user""" + # Setup mocks + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_result = MagicMock() + + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_connection.execute = AsyncMock(return_value=mock_result) + mock_result.mappings = MagicMock() + mock_result.mappings().fetchall = MagicMock(return_value=[ + {"id": 1, "name": "John"}, + {"id": 2, "name": "Jane"} + ]) + mock_mysql_helper.get_user_engine = AsyncMock(return_value=mock_engine) + + dql = "SELECT * FROM users" + mock_instance = MagicMock() + + # Run the method + result = await execute_dql_user(dql, mock_instance) + + # Assertions + mock_validate_safe_sql.assert_called_once_with(dql, is_root=False) + mock_mysql_helper.get_user_engine.assert_called_once_with(mock_instance) + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["id"] == 1 + assert result[0]["name"] == "John" + + @patch('app.mysql.mysql_execute.validate_safe_sql') + @patch('app.mysql.mysql_execute.MysqlHelper') + @patch('app.mysql.mysql_execute.DatabaseInstance') + @pytest.mark.asyncio + async def test_execute_dml_user(self, mock_database_instance, mock_mysql_helper, mock_validate_safe_sql): + """Test executing DML as user""" + # Setup mocks + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_result = MagicMock() + + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_connection.execute = AsyncMock(return_value=mock_result) + mock_connection.commit = AsyncMock() + + mock_result.rowcount = 1 + mock_result.lastrowid = 10 + mock_mysql_helper.get_user_engine = AsyncMock(return_value=mock_engine) + + dml = "INSERT INTO users (name) VALUES ('John')" + mock_instance = MagicMock() + + # Run the method + result = await execute_dml_user(dml, mock_instance) + + # Assertions + mock_validate_safe_sql.assert_called_once_with(dml, is_root=False) + mock_mysql_helper.get_user_engine.assert_called_once_with(mock_instance) + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + mock_connection.commit.assert_called_once() + assert isinstance(result, dict) + assert result["rowcount"] == 1 + assert result["lastrowid"] == 10 + + @patch('app.mysql.mysql_execute.validate_safe_sql') + @pytest.mark.asyncio + async def test_execute_sql_root_security_exception(self, mock_validate_safe_sql): + """Test executing SQL as root with security violation""" + # Setup mock to raise exception + mock_validate_safe_sql.side_effect = SQLSecurityException("Operation not allowed") + + sql = "DROP DATABASE test_db" + + # Run the method and check exception + with pytest.raises(SQLSecurityException): + await execute_sql_root(sql) + + # Assertions + mock_validate_safe_sql.assert_called_once_with(sql, is_root=True) \ No newline at end of file diff --git a/src/backend/tests/mysql_database/test_mysql_secure.py b/src/backend/tests/mysql_database/test_mysql_secure.py new file mode 100644 index 0000000..a0e0253 --- /dev/null +++ b/src/backend/tests/mysql_database/test_mysql_secure.py @@ -0,0 +1,88 @@ +import pytest +from unittest.mock import patch + +from app.mysql.mysql_secure import normalize_sql, match_operation, validate_safe_sql +from app.core.exceptions import SQLSecurityException + + +class TestMysqlSecure: + """Test cases for mysql_secure.py""" + + def test_normalize_sql(self): + """Test SQL normalization function""" + # Test removing comments + sql_with_comment = "SELECT * FROM users -- This is a comment" + assert normalize_sql(sql_with_comment) == "SELECT * FROM USERS" + + # Test removing extra spaces and newlines + sql_with_spaces = "SELECT * \nFROM\tusers" + assert normalize_sql(sql_with_spaces) == "SELECT * FROM USERS" + + # Test uppercase conversion + sql_lowercase = "select * from users" + assert normalize_sql(sql_lowercase) == "SELECT * FROM USERS" + + def test_match_operation(self): + """Test SQL operation matching""" + # Test exact match + assert match_operation("CREATE DATABASE test", ["CREATE DATABASE *"]) is True + + # Test wildcard match + assert match_operation("CREATE DATABASE mydb", ["CREATE DATABASE *"]) is True + + # Test no match + assert match_operation("DROP TABLE users", ["CREATE DATABASE *"]) is False + + # Test case insensitive match + assert match_operation("create database test", ["CREATE DATABASE *"]) is True + + # Test return False when no patterns match + assert match_operation("SELECT * FROM users", []) is False + + @patch('app.mysql.mysql_secure.config') + def test_validate_safe_sql_root_allowed(self, mock_config): + """Test SQL validation for root user with allowed operation""" + # Setup mock config + mock_config.sql_permissions.root.allowed_operations = ["CREATE DATABASE *"] + mock_config.sql_permissions.root.forbidden_operations = ["DROP DATABASE *"] + + # Should not raise exception for allowed operation + try: + validate_safe_sql("CREATE DATABASE test", is_root=True) + except SQLSecurityException: + pytest.fail("validate_safe_sql raised SQLSecurityException unexpectedly") + + @patch('app.mysql.mysql_secure.config') + def test_validate_safe_sql_root_forbidden(self, mock_config): + """Test SQL validation for root user with forbidden operation""" + # Setup mock config + mock_config.sql_permissions.root.allowed_operations = ["CREATE DATABASE *"] + mock_config.sql_permissions.root.forbidden_operations = ["DROP DATABASE *"] + + # Should raise exception for forbidden operation + with pytest.raises(SQLSecurityException): + validate_safe_sql("DROP DATABASE test", is_root=True) + + @patch('app.mysql.mysql_secure.config') + def test_validate_safe_sql_user_allowed(self, mock_config): + """Test SQL validation for normal user with allowed operation""" + # Setup mock config + mock_config.sql_permissions.normal.allowed_operations = ["SELECT *"] + mock_config.sql_permissions.normal.forbidden_operations = ["CREATE *", "DROP *", "ALTER *"] + + # Should not raise exception for allowed operation + try: + validate_safe_sql("SELECT * FROM users", is_root=False) + except SQLSecurityException: + pytest.fail("validate_safe_sql raised SQLSecurityException unexpectedly") + + @patch('app.mysql.mysql_secure.config') + def test_validate_safe_sql_user_not_allowed(self, mock_config): + """Test SQL validation for normal user with not allowed operation""" + # Setup mock config + mock_config.sql_permissions.normal.allowed_operations = ["SELECT *"] + mock_config.sql_permissions.normal.forbidden_operations = ["CREATE *", "DROP *", "ALTER *"] + + # Should raise exception for operation not in allowed list + with pytest.raises(SQLSecurityException): + validate_safe_sql("INSERT INTO users VALUES (1, 'John')", is_root=False) \ No newline at end of file diff --git a/src/backend/tests/postgresql_database/__init__.py b/src/backend/tests/postgresql_database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/tests/postgresql_database/test_postgres_database.py b/src/backend/tests/postgresql_database/test_postgres_database.py new file mode 100644 index 0000000..67f88b1 --- /dev/null +++ b/src/backend/tests/postgresql_database/test_postgres_database.py @@ -0,0 +1,379 @@ +# 然后直接导入 +from postgresql.postgres_database import PostgresHelper +from core.exceptions import InvalidOperationException + +import pytest +from unittest.mock import patch, MagicMock, AsyncMock + +class TestPostgresDatabase: + """Test cases for postgres_database.py""" + + @patch('postgresql.postgres_database.create_async_engine') + @patch('postgresql.postgres_database.PostgresConfig') + @pytest.mark.asyncio + async def test_init_root_engine(self, mock_postgres_config, mock_create_engine): + """Test initializing root engine""" + # Setup mocks + mock_engine = AsyncMock() + mock_create_engine.return_value = mock_engine + mock_config = MagicMock() + mock_config.sqlalchemy_database_url = "postgresql+asyncpg://postgres:password@localhost:3245" + mock_config.pool_size = 2 + mock_config.pool_recycle = 3600 + mock_config.pool_timeout = 30 + mock_config.max_overflow = 0 + + # Run the method + await PostgresHelper.init_root_engine(mock_config) + + # Assertions + mock_create_engine.assert_called_once_with( + mock_config.sqlalchemy_database_url, + connect_args={}, + pool_size=mock_config.pool_size, + pool_recycle=mock_config.pool_recycle, + pool_timeout=mock_config.pool_timeout, + max_overflow=mock_config.max_overflow + ) + assert PostgresHelper._root_engine == mock_engine + + @patch('postgresql.postgres_database.create_async_engine') + @patch('postgresql.postgres_database.PostgresConfig') + @patch('postgresql.postgres_database.DatabaseInstance') + @pytest.mark.asyncio + async def test_init_user_engine(self, mock_database_instance, mock_postgres_config, mock_create_engine): + """Test initializing user engine""" + # Setup mocks + mock_engine = AsyncMock() + mock_create_engine.return_value = mock_engine + mock_config = MagicMock() + mock_config.pool_size = 5 + mock_config.pool_recycle = 3600 + mock_config.pool_timeout = 30 + mock_config.max_overflow = 10 + + mock_instance = MagicMock() + mock_instance.instance_id = 1 + mock_instance.user_database_url = "postgresql+asyncpg://user:password@localhost:5432/db" + + # Run the method + await PostgresHelper.init_user_engine(mock_config, mock_instance.instance_id, mock_instance.user_database_url) + + # Assertions + mock_create_engine.assert_called_once_with( + mock_instance.user_database_url, + connect_args={}, + pool_size=mock_config.pool_size, + pool_recycle=mock_config.pool_recycle, + pool_timeout=mock_config.pool_timeout, + max_overflow=mock_config.max_overflow + ) + assert PostgresHelper._user_engine[mock_instance.instance_id] == mock_engine + + @pytest.mark.asyncio + async def test_get_root_engine_success(self): + """Test getting root engine when initialized""" + # Setup + mock_engine = AsyncMock() + PostgresHelper._root_engine = mock_engine + + # Run the method + result = await PostgresHelper.get_root_engine() + + # Assertions + assert result == mock_engine + + @pytest.mark.asyncio + async def test_get_root_engine_failure(self): + """Test getting root engine when not initialized""" + # Setup + PostgresHelper._root_engine = None + + # Run the method and check exception + with pytest.raises(InvalidOperationException): + await PostgresHelper.get_root_engine() + + @patch('postgresql.postgres_database.DatabaseInstance') + @pytest.mark.asyncio + async def test_get_user_engine_success(self, mock_database_instance): + """Test getting user engine when initialized""" + # Setup + mock_engine = AsyncMock() + mock_instance = MagicMock() + mock_instance.instance_id = 1 + PostgresHelper._user_engine = {mock_instance.instance_id: mock_engine} + + # Run the method + result = await PostgresHelper.get_user_engine(mock_instance) + + # Assertions + assert result == mock_engine + + @patch('postgresql.postgres_database.DatabaseInstance') + @pytest.mark.asyncio + async def test_get_user_engine_failure(self, mock_database_instance): + """Test getting user engine when not initialized""" + # Setup + mock_instance = MagicMock() + mock_instance.instance_id = 1 + PostgresHelper._user_engine = {} + + # Run the method and check exception + with pytest.raises(InvalidOperationException): + await PostgresHelper.get_user_engine(mock_instance) + + @pytest.mark.asyncio + async def test_close_root_engine(self): + """Test closing root engine""" + # Setup + mock_engine = AsyncMock() + mock_engine.dispose = AsyncMock() + PostgresHelper._root_engine = mock_engine + + # Run the method + await PostgresHelper.close_root_engine() + + # Assertions + mock_engine.dispose.assert_called_once() + assert PostgresHelper._root_engine is None + + @pytest.mark.asyncio + @patch('postgresql.postgres_database.DatabaseInstance') + async def test_close_user_engine(self, mock_database_instance): + """Test closing user engine""" + # Setup + mock_engine = AsyncMock() + mock_engine.dispose = AsyncMock() + mock_instance = MagicMock() + mock_instance.instance_id = 1 + PostgresHelper._user_engine = {mock_instance.instance_id: mock_engine} + + # Run the method + await PostgresHelper.close_user_engine(mock_instance) + + # Assertions + mock_engine.dispose.assert_called_once() + assert mock_instance.instance_id not in PostgresHelper._user_engine + + @pytest.mark.asyncio + async def test_close_all_engine(self): + """Test closing all engines""" + # Setup + mock_root_engine = AsyncMock() + mock_root_engine.dispose = AsyncMock() + PostgresHelper._root_engine = mock_root_engine + + mock_user_engine = AsyncMock() + mock_user_engine.dispose = AsyncMock() + PostgresHelper._user_engine = {1: mock_user_engine} + + # Run the method + await PostgresHelper.close_all_engine() + + # Assertions + mock_root_engine.dispose.assert_called_once() + mock_user_engine.dispose.assert_called_once() + assert PostgresHelper._root_engine is None + + def test_is_user_engine_exists(self): + """Test checking if user engine exists""" + # Setup + PostgresHelper._user_engine = {1: MagicMock(), 2: MagicMock()} + + # Test existing engine + assert PostgresHelper.is_user_engine_exists(1) is True + + # Test non-existing engine + assert PostgresHelper.is_user_engine_exists(3) is False + + def test_is_user_exists(self): + """Test checking if user exists in local cache""" + # Setup + PostgresHelper._user_exist = {"user1", "user2"} + + # Test existing user + assert PostgresHelper.is_user_exists("user1") is True + + # Test non-existing user + assert PostgresHelper.is_user_exists("user3") is False + + def test_add_user(self): + """Test adding user to local cache""" + # Setup + PostgresHelper._user_exist = set() + PostgresHelper._root_engine = MagicMock() + + # Run the method + PostgresHelper.add_user("new_user") + + # Assertions + assert "new_user" in PostgresHelper._user_exist + + @patch('postgresql.postgres_database.PostgresHelper.get_root_engine') + @pytest.mark.asyncio + async def test_is_user_exist_in_postgres(self, mock_get_root_engine): + """Test checking if user exists in PostgreSQL""" + # Setup + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_result = MagicMock() + + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_connection.execute = AsyncMock(return_value=mock_result) + mock_result.fetchone = MagicMock(return_value=MagicMock()) + mock_get_root_engine.return_value = mock_engine + + PostgresHelper._root_engine = mock_engine + + # Run the method + result = await PostgresHelper.is_user_exist_in_postgres("test_user") + + # Assertions + assert result is True + mock_get_root_engine.assert_called_once() + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + + @patch('postgresql.postgres_database.PostgresHelper.get_root_engine') + @pytest.mark.asyncio + async def test_is_user_exist_in_postgres_no_user(self, mock_get_root_engine): + """Test checking if user exists in PostgreSQL when user doesn't exist""" + # Setup + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_result = MagicMock() + + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_connection.execute = AsyncMock(return_value=mock_result) + mock_result.fetchone = MagicMock(return_value=None) + mock_get_root_engine.return_value = mock_engine + + PostgresHelper._root_engine = mock_engine + + # Run the method + result = await PostgresHelper.is_user_exist_in_postgres("test_user") + + # Assertions + assert result is False + mock_get_root_engine.assert_called_once() + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + + @pytest.mark.asyncio + async def test_is_user_exist_in_postgres_no_root_engine(self): + """Test checking if user exists in PostgreSQL when root engine is not initialized""" + # Setup + PostgresHelper._root_engine = None + + # Run the method and check exception + with pytest.raises(InvalidOperationException): + await PostgresHelper.is_user_exist_in_postgres("test_user") + + @patch('postgresql.postgres_database.PostgresHelper.get_root_engine') + @pytest.mark.asyncio + async def test_check_privilege(self, mock_get_root_engine): + """Test checking user privileges""" + # Setup + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_result = MagicMock() + + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_connection.execute = AsyncMock(return_value=mock_result) + mock_result.fetchall = MagicMock(return_value=[ + MagicMock(privilege_type="SELECT"), + MagicMock(privilege_type="INSERT"), + MagicMock(privilege_type="UPDATE"), + MagicMock(privilege_type="DELETE") + ]) + mock_get_root_engine.return_value = mock_engine + + PostgresHelper._root_engine = mock_engine + + # Run the method + result = await PostgresHelper.check_privilege("test_user", "test_db") + + # Assertions + assert result is True + mock_get_root_engine.assert_called_once() + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + + @patch('postgresql.postgres_database.PostgresHelper.get_root_engine') + @pytest.mark.asyncio + async def test_check_privilege_insufficient_privileges(self, mock_get_root_engine): + """Test checking user privileges when user has insufficient privileges""" + # Setup + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_result = MagicMock() + + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_connection.execute = AsyncMock(return_value=mock_result) + mock_result.fetchall = MagicMock(return_value=[ + MagicMock(privilege_type="SELECT") + ]) + mock_get_root_engine.return_value = mock_engine + + PostgresHelper._root_engine = mock_engine + + # Run the method + result = await PostgresHelper.check_privilege("test_user", "test_db") + + # Assertions + assert result is False + mock_get_root_engine.assert_called_once() + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + + @pytest.mark.asyncio + async def test_check_privilege_no_root_engine(self): + """Test checking user privileges when root engine is not initialized""" + # Setup + PostgresHelper._root_engine = None + + # Run the method and check exception + with pytest.raises(InvalidOperationException): + await PostgresHelper.check_privilege("test_user", "test_db") + + @patch('app.postgresql.postgres_database.PostgresHelper.get_root_engine') + @pytest.mark.asyncio + async def test_grant_user_privileges(self, mock_get_root_engine): + """Test granting user privileges""" + # Setup + mock_engine = MagicMock() + mock_connection = AsyncMock() + + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_connection.execute = AsyncMock() + mock_get_root_engine.return_value = mock_engine + + PostgresHelper._root_engine = mock_engine + + # Run the method + await PostgresHelper.grant_user_privileges("test_db", "test_user") + + # Assertions + mock_get_root_engine.assert_called_once() + mock_engine.connect.assert_called_once() + assert mock_connection.execute.call_count == 5 # 5 GRANT/ALTER statements + + @pytest.mark.asyncio + async def test_grant_user_privileges_no_root_engine(self): + """Test granting user privileges when root engine is not initialized""" + # Setup + PostgresHelper._root_engine = None + + # Run the method and check exception + with pytest.raises(InvalidOperationException): + await PostgresHelper.grant_user_privileges("test_db", "test_user") \ No newline at end of file diff --git a/src/backend/tests/postgresql_database/test_postgres_execute.py b/src/backend/tests/postgresql_database/test_postgres_execute.py new file mode 100644 index 0000000..0204254 --- /dev/null +++ b/src/backend/tests/postgresql_database/test_postgres_execute.py @@ -0,0 +1,227 @@ +import pytest +from unittest.mock import patch, MagicMock, AsyncMock + +from src.backend.app.postgresql.postgres_execute import ( + execute_sql_root, + execute_dql_root, + execute_dql_user, + execute_dml_user, + deploy_postgres_ddl +) +from src.backend.app.core.exceptions import SQLSecurityException, DatabaseOperationFailedException + + +class TestPostgresExecute: + """Test cases for postgres_execute.py""" + + @patch('app.postgresql.postgres_execute.validate_safe_sql') + @patch('app.postgresql.postgres_execute.PostgresHelper') + @pytest.mark.asyncio + async def test_execute_sql_root(self, mock_postgres_helper, mock_validate_safe_sql): + """Test executing SQL as root user""" + # Setup mocks + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_postgres_helper.get_root_engine = AsyncMock(return_value=mock_engine) + mock_connection.execute = AsyncMock() + + sql = "CREATE DATABASE test_db" + + # Run the method + await execute_sql_root(sql) + + # Assertions + mock_validate_safe_sql.assert_called_once_with(sql, is_root=True) + mock_postgres_helper.get_root_engine.assert_called_once() + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + + @patch('app.postgresql.postgres_execute.PostgresHelper') + @pytest.mark.asyncio + async def test_execute_dql_root(self, mock_postgres_helper): + """Test executing DQL as root user""" + # Setup mocks + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_result = MagicMock() + + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_connection.execute = AsyncMock(return_value=mock_result) + mock_result.mappings = MagicMock() + mock_result.mappings().fetchall = MagicMock(return_value=[ + {"id": 1, "name": "John"}, + {"id": 2, "name": "Jane"} + ]) + mock_postgres_helper.get_root_engine = AsyncMock(return_value=mock_engine) + + dql = "SELECT * FROM users" + + # Run the method + result = await execute_dql_root(dql) + + # Assertions + mock_postgres_helper.get_root_engine.assert_called_once() + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["id"] == 1 + assert result[0]["name"] == "John" + + @patch('app.postgresql.postgres_execute.validate_safe_sql') + @patch('app.postgresql.postgres_execute.PostgresHelper') + @patch('app.postgresql.postgres_execute.DatabaseInstance') + @pytest.mark.asyncio + async def test_execute_dql_user(self, mock_database_instance, mock_postgres_helper, mock_validate_safe_sql): + """Test executing DQL as user""" + # Setup mocks + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_result = MagicMock() + + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_connection.execute = AsyncMock(return_value=mock_result) + mock_result.mappings = MagicMock() + mock_result.mappings().fetchall = MagicMock(return_value=[ + {"id": 1, "name": "John"}, + {"id": 2, "name": "Jane"} + ]) + mock_postgres_helper.get_user_engine = AsyncMock(return_value=mock_engine) + + dql = "SELECT * FROM users" + mock_instance = MagicMock() + + # Run the method + result = await execute_dql_user(dql, mock_instance) + + # Assertions + mock_validate_safe_sql.assert_called_once_with(dql, is_root=False) + mock_postgres_helper.get_user_engine.assert_called_once_with(mock_instance) + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["id"] == 1 + assert result[0]["name"] == "John" + + @patch('app.postgresql.postgres_execute.validate_safe_sql') + @patch('app.postgresql.postgres_execute.PostgresHelper') + @patch('app.postgresql.postgres_execute.DatabaseInstance') + @pytest.mark.asyncio + async def test_execute_dml_user(self, mock_database_instance, mock_postgres_helper, mock_validate_safe_sql): + """Test executing DML as user""" + # Setup mocks + mock_engine = MagicMock() + mock_connection = AsyncMock() + mock_result = MagicMock() + + mock_connection.__aenter__ = AsyncMock(return_value=mock_connection) + mock_connection.__aexit__ = AsyncMock() + mock_engine.connect = MagicMock(return_value=mock_connection) + mock_connection.execute = AsyncMock(return_value=mock_result) + mock_connection.commit = AsyncMock() + + mock_result.rowcount = 1 + mock_result.lastrowid = 10 + mock_postgres_helper.get_user_engine = AsyncMock(return_value=mock_engine) + + dml = "INSERT INTO users (name) VALUES ('John')" + mock_instance = MagicMock() + + # Run the method + result = await execute_dml_user(dml, mock_instance) + + # Assertions + mock_validate_safe_sql.assert_called_once_with(dml, is_root=False) + mock_postgres_helper.get_user_engine.assert_called_once_with(mock_instance) + mock_engine.connect.assert_called_once() + mock_connection.execute.assert_called_once() + mock_connection.commit.assert_called_once() + assert isinstance(result, dict) + assert result["rowcount"] == 1 + assert result["lastrowid"] == 10 + + @patch('app.postgresql.postgres_execute.validate_safe_sql') + @pytest.mark.asyncio + async def test_execute_sql_root_security_exception(self, mock_validate_safe_sql): + """Test executing SQL as root with security violation""" + # Setup mock to raise exception + mock_validate_safe_sql.side_effect = SQLSecurityException("Operation not allowed") + + sql = "DROP DATABASE test_db" + + # Run the method and check exception + with pytest.raises(SQLSecurityException): + await execute_sql_root(sql) + + # Assertions + mock_validate_safe_sql.assert_called_once_with(sql, is_root=True) + + @patch('app.postgresql.postgres_execute.PostgresHelper') + @patch('app.postgresql.postgres_execute.create_async_engine') + @pytest.mark.asyncio + async def test_deploy_postgres_ddl(self, mock_create_async_engine, mock_postgres_helper): + """Test deploying PostgreSQL DDL""" + # Setup mocks + mock_root_engine = MagicMock() + mock_root_engine.url = MagicMock() + mock_root_engine.url.set = MagicMock(return_value="postgresql://test_db") + mock_postgres_helper.get_root_engine = AsyncMock(return_value=mock_root_engine) + + mock_temp_engine = MagicMock() + mock_temp_conn = AsyncMock() + mock_temp_conn.__aenter__ = AsyncMock(return_value=mock_temp_conn) + mock_temp_conn.__aexit__ = AsyncMock() + mock_temp_engine.connect = MagicMock(return_value=mock_temp_conn) + mock_temp_engine.dispose = AsyncMock() + mock_create_async_engine.side_effect = [mock_temp_engine, MagicMock()] + + mock_db_engine = MagicMock() + mock_db_conn = AsyncMock() + mock_db_conn.__aenter__ = AsyncMock(return_value=mock_db_conn) + mock_db_conn.__aexit__ = AsyncMock() + mock_db_conn.commit = AsyncMock() + mock_db_engine.connect = MagicMock(return_value=mock_db_conn) + mock_db_engine.dispose = AsyncMock() + + # Override second call to create_async_engine + def side_effect(*args, **kwargs): + if mock_create_async_engine.call_count == 2: + return mock_db_engine + return mock_temp_engine + + mock_create_async_engine.side_effect = side_effect + + statements = [ + "CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(50))", + "INSERT INTO users (name) VALUES ('John')" + ] + + # Run the method + await deploy_postgres_ddl("test_db", statements) + + # Assertions + assert mock_create_async_engine.call_count == 2 + mock_postgres_helper.get_root_engine.assert_called_once() + assert mock_temp_engine.dispose.call_count == 1 + + @patch('app.postgresql.postgres_execute.PostgresHelper') + @patch('app.postgresql.postgres_execute.create_async_engine') + @pytest.mark.asyncio + async def test_deploy_postgres_ddl_exception(self, mock_create_async_engine, mock_postgres_helper): + """Test deploying PostgreSQL DDL with exception""" + # Setup mocks to raise exception + mock_postgres_helper.get_root_engine = AsyncMock(side_effect=Exception("Connection failed")) + + statements = ["CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(50))"] + + # Run the method and check exception + with pytest.raises(DatabaseOperationFailedException): + await deploy_postgres_ddl("test_db", statements) \ No newline at end of file diff --git a/src/backend/tests/postgresql_database/test_postgres_secure.py b/src/backend/tests/postgresql_database/test_postgres_secure.py new file mode 100644 index 0000000..a578e4d --- /dev/null +++ b/src/backend/tests/postgresql_database/test_postgres_secure.py @@ -0,0 +1,88 @@ +import pytest +from unittest.mock import patch + +from src.backend.app.postgresql.postgres_secure import normalize_sql, match_operation, validate_safe_sql +from src.backend.app.core.exceptions import SQLSecurityException + + +class TestPostgresSecure: + """Test cases for postgres_secure.py""" + + def test_normalize_sql(self): + """Test SQL normalization function""" + # Test removing comments + sql_with_comment = "SELECT * FROM users -- This is a comment" + assert normalize_sql(sql_with_comment) == "SELECT * FROM USERS" + + # Test removing extra spaces and newlines + sql_with_spaces = "SELECT * \nFROM\tusers" + assert normalize_sql(sql_with_spaces) == "SELECT * FROM USERS" + + # Test uppercase conversion + sql_lowercase = "select * from users" + assert normalize_sql(sql_lowercase) == "SELECT * FROM USERS" + + def test_match_operation(self): + """Test SQL operation matching""" + # Test exact match + assert match_operation("CREATE DATABASE test", ["CREATE DATABASE *"]) is True + + # Test wildcard match + assert match_operation("CREATE DATABASE mydb", ["CREATE DATABASE *"]) is True + + # Test no match + assert match_operation("DROP TABLE users", ["CREATE DATABASE *"]) is False + + # Test case insensitive match + assert match_operation("create database test", ["CREATE DATABASE *"]) is True + + # Test return False when no patterns match + assert match_operation("SELECT * FROM users", []) is False + + @patch('app.postgresql.postgres_secure.config') + def test_validate_safe_sql_root_allowed(self, mock_config): + """Test SQL validation for root user with allowed operation""" + # Setup mock config + mock_config.sql_permissions.root.allowed_operations = ["CREATE DATABASE *"] + mock_config.sql_permissions.root.forbidden_operations = ["DROP DATABASE *"] + + # Should not raise exception for allowed operation + try: + validate_safe_sql("CREATE DATABASE test", is_root=True) + except SQLSecurityException: + pytest.fail("validate_safe_sql raised SQLSecurityException unexpectedly") + + @patch('app.postgresql.postgres_secure.config') + def test_validate_safe_sql_root_forbidden(self, mock_config): + """Test SQL validation for root user with forbidden operation""" + # Setup mock config + mock_config.sql_permissions.root.allowed_operations = ["CREATE DATABASE *"] + mock_config.sql_permissions.root.forbidden_operations = ["DROP DATABASE *"] + + # Should raise exception for forbidden operation + with pytest.raises(SQLSecurityException): + validate_safe_sql("DROP DATABASE test", is_root=True) + + @patch('app.postgresql.postgres_secure.config') + def test_validate_safe_sql_user_allowed(self, mock_config): + """Test SQL validation for normal user with allowed operation""" + # Setup mock config + mock_config.sql_permissions.normal.allowed_operations = ["SELECT *"] + mock_config.sql_permissions.normal.forbidden_operations = ["CREATE *", "DROP *", "ALTER *"] + + # Should not raise exception for allowed operation + try: + validate_safe_sql("SELECT * FROM users", is_root=False) + except SQLSecurityException: + pytest.fail("validate_safe_sql raised SQLSecurityException unexpectedly") + + @patch('app.postgresql.postgres_secure.config') + def test_validate_safe_sql_user_not_allowed(self, mock_config): + """Test SQL validation for normal user with not allowed operation""" + # Setup mock config + mock_config.sql_permissions.normal.allowed_operations = ["SELECT *"] + mock_config.sql_permissions.normal.forbidden_operations = ["CREATE *", "DROP *", "ALTER *"] + + # Should raise exception for operation not in allowed list + with pytest.raises(SQLSecurityException): + validate_safe_sql("INSERT INTO users VALUES (1, 'John')", is_root=False) \ No newline at end of file diff --git a/src/backend/tests/server/__init__.py b/src/backend/tests/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/tests/server/test_server.py b/src/backend/tests/server/test_server.py new file mode 100644 index 0000000..784a4d8 --- /dev/null +++ b/src/backend/tests/server/test_server.py @@ -0,0 +1,90 @@ +import pytest +from unittest.mock import patch, MagicMock + +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import AsyncEngine + +from app import server +from app.core.database import PsqlHelper + + +class TestServer: + """Test cases for server.py""" + + def test_server_app_creation(self): + """Test that the FastAPI app is created with correct configuration""" + # Check that app is an instance of FastAPI + assert isinstance(server.app, FastAPI) + + # Check that app has correct title, description, and version from config + assert server.app.title == "Auto Database Deployment" + assert server.app.description == "自然语言创建、操作数据库" + assert server.app.version == "1.0.0" + + # Check that the app has a lifespan context (we can verify this by checking if it's set) + # The lifespan parameter is used during app initialization, not stored as an attribute + + @pytest.mark.asyncio + async def test_startup_services(self): + """Test the startup_services function""" + # Create a mock FastAPI app + mock_app = MagicMock(spec=FastAPI) + mock_app.state = MagicMock() + + # Mock PsqlHelper.init_conn_psql to return a mock engine + mock_engine = MagicMock(spec=AsyncEngine) + with patch('app.core.database.PsqlHelper.init_conn_psql', return_value=mock_engine): + # Call startup_services + await server.startup_services(mock_app) + + # Verify that app.state.config is set + assert hasattr(mock_app.state, 'config') + assert mock_app.state.config == server.config + + # Verify that app.state.psql_engine is set to the mock engine + assert mock_app.state.psql_engine == mock_engine + + @pytest.mark.asyncio + async def test_close_services(self): + """Test the close_services function""" + # Create a mock FastAPI app + mock_app = MagicMock(spec=FastAPI) + mock_app.state = MagicMock() + + # Create a mock engine + mock_engine = MagicMock(spec=AsyncEngine) + mock_app.state.psql_engine = mock_engine + + # Mock PsqlHelper.close_conn_psql + with patch('app.core.database.PsqlHelper.close_conn_psql') as mock_close_conn: + # Call close_services + await server.close_services(mock_app) + + # Verify that PsqlHelper.close_conn_psql was called with the mock engine + mock_close_conn.assert_called_once_with(mock_engine) + + @pytest.mark.asyncio + async def test_lifespan(self): + """Test the lifespan context manager""" + # Create a mock FastAPI app + mock_app = MagicMock(spec=FastAPI) + mock_app.state = MagicMock() + + # Create a mock engine + mock_engine = MagicMock(spec=AsyncEngine) + mock_app.state.psql_engine = mock_engine + + # Mock both startup and close services + with patch('app.core.database.PsqlHelper.init_conn_psql', return_value=mock_engine) as mock_init_conn, \ + patch('app.core.database.PsqlHelper.close_conn_psql') as mock_close_conn: + + # Test the lifespan context manager + async with server.lifespan(mock_app): + # Verify that startup_services was called + assert hasattr(mock_app.state, 'config') + assert mock_app.state.config == server.config + assert mock_app.state.psql_engine == mock_engine + + # Verify that both functions were called + mock_init_conn.assert_called_once() + mock_close_conn.assert_called_once_with(mock_engine) \ No newline at end of file diff --git a/src/backend/tests/service/test_cache.py b/src/backend/tests/service/test_cache.py new file mode 100644 index 0000000..a40ab26 --- /dev/null +++ b/src/backend/tests/service/test_cache.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +Redis缓存功能测试脚本 + +测试缓存服务的基本功能,包括: +1. 基本的set/get操作 +2. get_or_set逻辑 +3. 序列化和反序列化 +4. 缓存过期 +""" + +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.redis_client.redis import init_redis, get_redis +from app.redis_client.cache_service import CacheService + +async def test_basic_set_get(): + """测试基本的set和get操作""" + print("\n=== 测试基本的set和get操作 ===") + + # 初始化Redis客户端 + await init_redis() + + # 测试数据 + test_key = "test:cache:basic" + test_value = "Hello, Redis!" + + # 设置缓存 + set_result = await CacheService.set(test_key, test_value) + print(f"设置缓存结果: {set_result}") + assert set_result is True, "设置缓存失败" + + # 获取缓存 + get_result = await CacheService.get(test_key) + print(f"获取缓存结果: {get_result}") + assert get_result == test_value, f"获取缓存失败,预期: {test_value}, 实际: {get_result}" + + # 删除缓存 + delete_result = await CacheService.delete(test_key) + print(f"删除缓存结果: {delete_result}") + assert delete_result is True, "删除缓存失败" + + # 验证缓存已删除 + get_result = await CacheService.get(test_key) + print(f"删除后获取缓存结果: {get_result}") + assert get_result is None, "缓存删除失败" + + print("✅ 基本的set和get操作测试通过") + +async def test_complex_object(): + """测试复杂对象的序列化和反序列化""" + print("\n=== 测试复杂对象的序列化和反序列化 ===") + + # 初始化Redis客户端 + await init_redis() + + # 测试数据 - 复杂对象 + test_key = "test:cache:complex" + test_value = { + "name": "测试用户", + "age": 30, + "email": "test@example.com", + "roles": ["admin", "user"], + "settings": { + "theme": "dark", + "notifications": True + } + } + + # 设置缓存 + set_result = await CacheService.set(test_key, test_value) + print(f"设置复杂对象缓存结果: {set_result}") + assert set_result is True, "设置复杂对象缓存失败" + + # 获取缓存 + get_result = await CacheService.get(test_key) + print(f"获取复杂对象缓存结果: {get_result}") + assert get_result == test_value, f"获取复杂对象缓存失败,预期: {test_value}, 实际: {get_result}" + + # 验证数据类型 + assert isinstance(get_result, dict), "获取的结果不是字典类型" + assert isinstance(get_result["roles"], list), "嵌套的roles不是列表类型" + assert isinstance(get_result["settings"], dict), "嵌套的settings不是字典类型" + + # 删除缓存 + await CacheService.delete(test_key) + + print("✅ 复杂对象的序列化和反序列化测试通过") + +async def test_get_or_set(): + """测试get_or_set功能""" + print("\n=== 测试get_or_set功能 ===") + + # 初始化Redis客户端 + await init_redis() + + # 测试数据 + test_key = "test:cache:get_or_set" + test_value = "数据来自数据库" + + # 模拟从数据库获取数据的函数 + async def fetch_from_db(): + print(" 模拟从数据库获取数据") + await asyncio.sleep(0.1) # 模拟数据库延迟 + return test_value + + # 第一次调用 - 缓存不存在,会调用fetch_from_db + result = await CacheService.get_or_set(test_key, fetch_from_db, ttl=10) + print(f"第一次调用结果: {result.data}, 来自缓存: {result.from_cache}") + assert result.data == test_value, "第一次调用结果不正确" + assert result.from_cache is False, "第一次调用应该来自数据库,而不是缓存" + + # 第二次调用 - 缓存已存在,不会调用fetch_from_db + result = await CacheService.get_or_set(test_key, fetch_from_db, ttl=10) + print(f"第二次调用结果: {result.data}, 来自缓存: {result.from_cache}") + assert result.data == test_value, "第二次调用结果不正确" + assert result.from_cache is True, "第二次调用应该来自缓存" + + # 删除缓存 + await CacheService.delete(test_key) + + print("✅ get_or_set功能测试通过") + +async def test_cache_ttl(): + """测试缓存过期功能""" + print("\n=== 测试缓存过期功能 ===") + + # 初始化Redis客户端 + await init_redis() + + # 测试数据 + test_key = "test:cache:ttl" + test_value = "临时数据" + + # 设置缓存,TTL为1秒 + set_result = await CacheService.set(test_key, test_value, ttl=1) + print(f"设置缓存结果: {set_result}") + assert set_result is True, "设置缓存失败" + + # 获取缓存 - 应该存在 + get_result = await CacheService.get(test_key) + print(f"设置后立即获取: {get_result}") + assert get_result == test_value, "缓存设置后立即获取失败" + + # 等待2秒,缓存应该过期 + print("等待2秒,让缓存过期...") + await asyncio.sleep(2) + + # 获取缓存 - 应该不存在 + get_result = await CacheService.get(test_key) + print(f"过期后获取: {get_result}") + assert get_result is None, "缓存过期功能失败" + + print("✅ 缓存过期功能测试通过") + +async def main(): + """运行所有测试""" + print("开始Redis缓存功能测试") + + try: + # 运行所有测试 + await test_basic_set_get() + await test_complex_object() + await test_get_or_set() + await test_cache_ttl() + + print("\n🎉 所有测试通过!") + return 0 + except Exception as e: + print(f"\n❌ 测试失败: {e}") + import traceback + traceback.print_exc() + return 1 + +if __name__ == "__main__": + exit_code = asyncio.run(main()) + sys.exit(exit_code) diff --git a/src/backend/tests/service/test_cache_service.py b/src/backend/tests/service/test_cache_service.py new file mode 100644 index 0000000..7fc2893 --- /dev/null +++ b/src/backend/tests/service/test_cache_service.py @@ -0,0 +1,177 @@ +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.redis_client.cache_service import cache_service +from app.redis_client.redis import init_redis, close_redis +from app.core.config import config + +async def test_cache_basic(): + """测试基本的缓存操作""" + print("=== 测试基本缓存操作 ===") + + # 设置缓存 + key = "test:basic:key" + value = "test_value" + success = await cache_service.set(key, value, ttl=30) + print(f"设置缓存 {key} = {value}: {'成功' if success else '失败'}") + + # 获取缓存 + cached_value = await cache_service.get(key) + print(f"获取缓存 {key}: {cached_value}") + assert cached_value == value, f"获取的缓存值与设置的值不匹配: {cached_value} != {value}" + + # 检查缓存是否存在 + exists = await cache_service.exists(key) + print(f"检查缓存 {key} 是否存在: {'存在' if exists else '不存在'}") + assert exists, f"缓存 {key} 应该存在" + + # 删除缓存 + deleted = await cache_service.delete(key) + print(f"删除缓存 {key}: {'成功' if deleted else '失败'}") + + # 再次检查缓存是否存在 + exists = await cache_service.exists(key) + print(f"再次检查缓存 {key} 是否存在: {'存在' if exists else '不存在'}") + assert not exists, f"缓存 {key} 应该不存在" + + print("基本缓存操作测试通过") + +async def test_cache_complex_object(): + """测试复杂对象的缓存操作""" + print("\n=== 测试复杂对象缓存操作 ===") + + # 测试字典 + key = "test:complex:dict" + value = {"name": "test", "age": 18, "tags": ["a", "b", "c"]} + success = await cache_service.set(key, value, ttl=30) + print(f"设置字典缓存 {key} = {value}: {'成功' if success else '失败'}") + + cached_value = await cache_service.get(key) + print(f"获取字典缓存 {key}: {cached_value}") + assert cached_value == value, f"获取的字典缓存值与设置的值不匹配" + + await cache_service.delete(key) + + # 测试列表 + key = "test:complex:list" + value = [1, 2, 3, 4, 5] + success = await cache_service.set(key, value, ttl=30) + print(f"设置列表缓存 {key} = {value}: {'成功' if success else '失败'}") + + cached_value = await cache_service.get(key) + print(f"获取列表缓存 {key}: {cached_value}") + assert cached_value == value, f"获取的列表缓存值与设置的值不匹配" + + await cache_service.delete(key) + print("复杂对象缓存操作测试通过") + +async def test_get_or_set(): + """测试get_or_set功能""" + print("\n=== 测试get_or_set功能 ===") + + # 定义获取数据的函数 + async def fetch_data(): + print("执行fetch_data函数获取数据") + return {"data": "fetched_data", "timestamp": "2023-12-27"} + + # 第一次调用,应该执行函数 + key = "test:get_or_set:key" + result = await cache_service.get_or_set(key, fetch_data, ttl=30) + print(f"第一次调用get_or_set结果: {result.data}, from_cache: {result.from_cache}") + assert not result.from_cache, "第一次调用应该不来自缓存" + + # 第二次调用,应该从缓存获取 + result = await cache_service.get_or_set(key, fetch_data, ttl=30) + print(f"第二次调用get_or_set结果: {result.data}, from_cache: {result.from_cache}") + assert result.from_cache, "第二次调用应该来自缓存" + + await cache_service.delete(key) + print("get_or_set功能测试通过") + +async def test_cache_ttl(): + """测试缓存过期时间""" + print("\n=== 测试缓存过期时间 ===") + + key = "test:ttl:key" + value = "ttl_test_value" + + # 设置缓存,过期时间1秒 + success = await cache_service.set(key, value, ttl=1) + print(f"设置缓存 {key} = {value}, ttl=1秒: {'成功' if success else '失败'}") + + # 立即获取,应该存在 + cached_value = await cache_service.get(key) + print(f"立即获取缓存 {key}: {cached_value}") + assert cached_value == value, "缓存应该存在" + + # 等待2秒 + print("等待2秒...") + await asyncio.sleep(2) + + # 再次获取,应该不存在 + cached_value = await cache_service.get(key) + print(f"2秒后获取缓存 {key}: {cached_value}") + assert cached_value is None, "缓存应该已过期" + + print("缓存过期时间测试通过") + +async def test_delete_pattern(): + """测试根据模式删除缓存""" + print("\n=== 测试根据模式删除缓存 ===") + + # 设置多个相关缓存 + keys = ["test:pattern:key1", "test:pattern:key2", "test:pattern:key3"] + values = ["value1", "value2", "value3"] + + for key, value in zip(keys, values): + await cache_service.set(key, value, ttl=300) + print(f"设置缓存 {key} = {value}") + + # 验证缓存存在 + for key in keys: + value = await cache_service.get(key) + assert value is not None, f"缓存 {key} 应该存在" + + # 根据模式删除缓存 + pattern = "test:pattern:*" + deleted_count = await cache_service.delete_pattern(pattern) + print(f"根据模式 {pattern} 删除缓存,删除了 {deleted_count} 个缓存") + assert deleted_count == len(keys), f"应该删除 {len(keys)} 个缓存,实际删除了 {deleted_count} 个" + + # 验证缓存已删除 + for key in keys: + value = await cache_service.get(key) + assert value is None, f"缓存 {key} 应该已被删除" + + print("根据模式删除缓存测试通过") + +async def main(): + """主测试函数""" + print(f"测试Redis缓存服务,配置: {config.redis.host}:{config.redis.port}") + + try: + # 初始化Redis连接 + await init_redis() + + # 运行所有测试 + await test_cache_basic() + await test_cache_complex_object() + await test_get_or_set() + await test_cache_ttl() + await test_delete_pattern() + + print("\n=== 所有测试通过!===\n") + except Exception as e: + print(f"\n=== 测试失败: {e} ===\n") + import traceback + traceback.print_exc() + finally: + # 关闭Redis连接 + await close_redis() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/backend/tests/service/test_chroma_docker.py b/src/backend/tests/service/test_chroma_docker.py new file mode 100644 index 0000000..81883a8 --- /dev/null +++ b/src/backend/tests/service/test_chroma_docker.py @@ -0,0 +1,112 @@ +""" +测试 Docker 模式下的 ChromaDB 连接。 + +运行方式: + 1. 先启动 ChromaDB 容器:docker-compose up chromadb -d + 2. 运行测试:python test_chroma_docker.py +""" + +import asyncio + + +async def test_docker_mode(): + """测试 Docker 模式的 ChromaDB 连接""" + print("=" * 60) + print("测试 Docker 模式 ChromaDB 连接") + print("=" * 60) + + import chromadb + from chromadb.config import Settings + + # Docker 模式连接参数 + host = "localhost" # 本地测试时使用 localhost + port = 8100 # docker-compose 中映射的端口 + + try: + # 创建 HTTP 客户端 + client = chromadb.HttpClient( + host=host, + port=port, + settings=Settings(anonymized_telemetry=False) + ) + + # 测试心跳 + heartbeat = client.heartbeat() + print(f"✅ ChromaDB 服务连接成功!") + print(f" 地址: {host}:{port}") + print(f" 心跳: {heartbeat}") + + # 列出现有集合 + collections = client.list_collections() + print(f" 现有集合数: {len(collections)}") + + print(f"\n✅ Docker 模式测试通过!") + return True + + except Exception as e: + print(f"❌ 连接失败: {e}") + print("\n提示:请确保 ChromaDB 容器已启动") + print(" docker-compose up chromadb -d") + return False + + +def test_embedded_mode(): + """测试内嵌模式(本地开发)""" + print("\n" + "=" * 60) + print("测试内嵌模式 ChromaDB(本地开发)") + print("=" * 60) + + import chromadb + from chromadb.config import Settings + + try: + # 内嵌模式 + client = chromadb.Client( + settings=Settings(anonymized_telemetry=False) + ) + + print(f"✅ ChromaDB 内嵌模式正常") + print(f" 无需 Docker 容器") + + # 测试功能 + collection = client.get_or_create_collection("embed_test") + collection.add( + documents=["内嵌测试"], + ids=["embed1"] + ) + print(f" 集合功能正常") + + return True + + except Exception as e: + print(f"❌ 内嵌模式失败: {e}") + return False + + +async def main(): + print("\n🔍 ChromaDB 连接测试\n") + + # 测试内嵌模式 + embedded_ok = test_embedded_mode() + + # 测试 Docker 模式 + docker_ok = await test_docker_mode() + + # 汇总 + print("\n" + "=" * 60) + print("测试结果") + print("=" * 60) + print(f" 内嵌模式: {'✅ 通过' if embedded_ok else '❌ 失败'}") + print(f" Docker模式: {'✅ 通过' if docker_ok else '❌ 未启动/失败'}") + + if embedded_ok: + print("\n💡 当前可用模式:") + print(" - 本地开发:使用内嵌模式(config.yaml 中 use_http_client: false)") + if docker_ok: + print(" - Docker部署:使用HTTP客户端模式(config.yaml 中 use_http_client: true)") + else: + print(" - Docker部署:需先启动容器 `docker-compose up chromadb -d`") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/backend/tests/service/test_embedding.py b/src/backend/tests/service/test_embedding.py new file mode 100644 index 0000000..33066ef --- /dev/null +++ b/src/backend/tests/service/test_embedding.py @@ -0,0 +1,31 @@ +""" +Embedding 服务测试脚本。 + +运行方式: + cd src/backend + python -m tests.service.test_embedding +""" + +import sys +import os + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from openai import OpenAI +from app.config import settings + +# 从配置文件获取 API key +client = OpenAI( + api_key=settings.embedding.api_key, + base_url=settings.embedding.base_url +) + +completion = client.embeddings.create( + model=settings.embedding.model, + input='衣服的质量杠杠的,很漂亮,不枉我等了这么久啊,喜欢,以后还来这里买', + dimensions=settings.embedding.dimensions, + encoding_format="float" +) + +print(completion.model_dump_json()) \ No newline at end of file diff --git a/src/backend/tests/service/test_mysql_service.py b/src/backend/tests/service/test_mysql_service.py new file mode 100644 index 0000000..b9a6af6 --- /dev/null +++ b/src/backend/tests/service/test_mysql_service.py @@ -0,0 +1,263 @@ +""" +MySQL 用户配置服务单元测试。 + +测试拆分后的各个函数的功能。 +""" + +import pytest +from unittest.mock import AsyncMock, patch +from sqlalchemy import URL + +from service.mysql_service import ( + create_mysql_user_with_plugin, + grant_user_privileges, + flush_privileges, + alter_user_plugin, + build_mysql_url, + init_user_engine_with_plugin, + create_mysql_user, + _escape_sql_string +) + + +class TestMySQLUserService: + """MySQL 用户服务测试类""" + + @pytest.fixture + def mock_execute_sql_root(self): + """模拟 execute_sql_root 函数""" + with patch('service.mysql_service._execute_raw_sql') as mock: + yield mock + + @pytest.fixture + def mock_mysql_helper(self): + """模拟 MysqlHelper 类""" + with patch('service.mysql_service.MysqlHelper') as mock: + yield mock + + @pytest.fixture + def mock_config(self): + """模拟 config 对象""" + with patch('service.mysql_service.config') as mock: + yield mock + + @pytest.fixture + def mock_log(self): + """模拟 log 对象""" + with patch('service.mysql_service.log') as mock: + yield mock + + @pytest.mark.asyncio + async def test_create_mysql_user_with_plugin_default(self, mock_execute_sql_root, mock_log): + """测试使用默认插件创建用户""" + db_username = "test_user" + db_password = "password123" + plugin = "sha256_password" + + await create_mysql_user_with_plugin(db_username, db_password) + + # 验证调用了两次 _execute_raw_sql(% 和 localhost) + assert mock_execute_sql_root.call_count == 2 + + # 验证 SQL 语句 + escaped_username = _escape_sql_string(db_username) + escaped_host1 = _escape_sql_string("%") + escaped_host2 = _escape_sql_string("localhost") + escaped_password = _escape_sql_string(db_password) + + expected_calls = [ + f"CREATE USER '{escaped_username[1:-1]}'@'{escaped_host1[1:-1]}' IDENTIFIED WITH {plugin} BY {escaped_password}", + f"CREATE USER '{escaped_username[1:-1]}'@'{escaped_host2[1:-1]}' IDENTIFIED WITH {plugin} BY {escaped_password}" + ] + + actual_calls = [call.args[0] for call in mock_execute_sql_root.call_args_list] + assert actual_calls == expected_calls + + # 验证日志调用 + assert mock_log.info.call_count == 4 # 2次执行日志 + 2次创建日志 + + @pytest.mark.asyncio + async def test_create_mysql_user_with_plugin_custom(self, mock_execute_sql_root, mock_log): + """测试使用自定义插件创建用户""" + db_username = "test_user" + db_password = "password123" + plugin = "mysql_native_password" + + await create_mysql_user_with_plugin(db_username, db_password, plugin) + + # 验证 SQL 语句包含正确的插件 + actual_calls = [call.args[0] for call in mock_execute_sql_root.call_args_list] + for sql in actual_calls: + assert plugin in sql + + @pytest.mark.asyncio + async def test_grant_user_privileges(self, mock_execute_sql_root, mock_log): + """测试授予用户权限""" + db_name = "test_db" + db_username = "test_user" + + await grant_user_privileges(db_name, db_username) + + # 验证调用了两次 _execute_raw_sql + assert mock_execute_sql_root.call_count == 2 + + # 验证 SQL 语句 + escaped_username = _escape_sql_string(db_username) + escaped_host1 = _escape_sql_string("%") + escaped_host2 = _escape_sql_string("localhost") + + expected_calls = [ + f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{escaped_username[1:-1]}'@'{escaped_host1[1:-1]}'", + f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{escaped_username[1:-1]}'@'{escaped_host2[1:-1]}'" + ] + + actual_calls = [call.args[0] for call in mock_execute_sql_root.call_args_list] + assert actual_calls == expected_calls + + @pytest.mark.asyncio + async def test_flush_privileges_success(self, mock_execute_sql_root, mock_log): + """测试刷新权限成功""" + with patch('service.mysql_service.execute_sql_root') as mock_exec_root: + await flush_privileges() + + mock_exec_root.assert_called_once_with("FLUSH PRIVILEGES") + mock_log.info.assert_called_once_with("[MySQL] Privileges flushed successfully") + + @pytest.mark.asyncio + async def test_flush_privileges_failure(self, mock_log): + """测试刷新权限失败""" + with patch('service.mysql_service.execute_sql_root', side_effect=Exception("Flush failed")) as mock_exec_root: + await flush_privileges() + + mock_log.warning.assert_called_once() + + @pytest.mark.asyncio + async def test_alter_user_plugin(self, mock_execute_sql_root, mock_log): + """测试修改用户插件""" + db_username = "test_user" + db_password = "password123" + plugin = "mysql_native_password" + + await alter_user_plugin(db_username, db_password, plugin) + + # 验证调用了两次 _execute_raw_sql + assert mock_execute_sql_root.call_count == 2 + + # 验证 SQL 语句 + escaped_username = _escape_sql_string(db_username) + escaped_host1 = _escape_sql_string("%") + escaped_host2 = _escape_sql_string("localhost") + escaped_password = _escape_sql_string(db_password) + + expected_calls = [ + f"ALTER USER '{escaped_username[1:-1]}'@'{escaped_host1[1:-1]}' IDENTIFIED WITH {plugin} BY {escaped_password}", + f"ALTER USER '{escaped_username[1:-1]}'@'{escaped_host2[1:-1]}' IDENTIFIED WITH {plugin} BY {escaped_password}" + ] + + actual_calls = [call.args[0] for call in mock_execute_sql_root.call_args_list] + assert actual_calls == expected_calls + + def test_build_mysql_url_default(self): + """测试构建默认 MySQL URL""" + db_username = "test_user" + db_password = "password123" + db_name = "test_db" + plugin = "sha256_password" + + url = build_mysql_url(db_username, db_password, db_name) + + assert url.username == db_username + assert url.password == db_password + assert url.database == db_name + assert url.host == "localhost" + assert url.port == 3306 + assert url.query["auth_plugin"] == plugin + assert url.query["charset"] == "utf8mb4" + + def test_build_mysql_url_custom(self): + """测试构建自定义 MySQL URL""" + db_username = "test_user" + db_password = "password123" + db_name = "test_db" + plugin = "mysql_native_password" + host = "192.168.1.100" + port = 3307 + + url = build_mysql_url(db_username, db_password, db_name, plugin, host, port) + + assert url.username == db_username + assert url.password == db_password + assert url.database == db_name + assert url.host == host + assert url.port == port + assert url.query["auth_plugin"] == plugin + + @pytest.mark.asyncio + async def test_init_user_engine_with_plugin_success(self, mock_mysql_helper, mock_config, mock_log): + """测试初始化用户引擎成功""" + db_username = "test_user" + db_password = "password123" + db_name = "test_db" + instance_id = 1 + plugin = "sha256_password" + + # 模拟 init_user_engine 成功 + mock_mysql_helper.init_user_engine = AsyncMock() + + result = await init_user_engine_with_plugin(db_username, db_password, db_name, instance_id, plugin) + + assert result is True + mock_mysql_helper.init_user_engine.assert_called_once() + mock_log.info.assert_called_once() + + @pytest.mark.asyncio + async def test_init_user_engine_with_plugin_failure(self, mock_mysql_helper, mock_config, mock_log): + """测试初始化用户引擎失败""" + db_username = "test_user" + db_password = "password123" + db_name = "test_db" + instance_id = 1 + plugin = "sha256_password" + + # 模拟 init_user_engine 失败 + mock_mysql_helper.init_user_engine = AsyncMock(side_effect=Exception("Connection failed")) + + result = await init_user_engine_with_plugin(db_username, db_password, db_name, instance_id, plugin) + + assert result is False + mock_log.warning.assert_called_once() + + @pytest.mark.asyncio + async def test_create_mysql_user_success(self, mock_execute_sql_root, mock_mysql_helper, mock_config, mock_log): + """测试创建 MySQL 用户成功(完整流程)""" + db_name = "test_db" + db_username = "test_user" + db_password = "password123" + instance_id = 1 + + # 模拟 init_user_engine 成功 + mock_mysql_helper.init_user_engine = AsyncMock() + + await create_mysql_user(db_name, db_username, db_password, instance_id) + + # 验证调用顺序和次数 + assert mock_execute_sql_root.call_count >= 5 # 创建用户2次 + 授予权限2次 + 其他调用 + mock_mysql_helper.init_user_engine.assert_called_once() + + @pytest.mark.asyncio + async def test_create_mysql_user_fallback(self, mock_execute_sql_root, mock_mysql_helper, mock_config, mock_log): + """测试创建 MySQL 用户时的插件回退机制""" + db_name = "test_db" + db_username = "test_user" + db_password = "password123" + instance_id = 1 + + # 模拟第一次 init_user_engine 失败,第二次成功 + mock_mysql_helper.init_user_engine = AsyncMock(side_effect=[Exception("First try failed"), None]) + + await create_mysql_user(db_name, db_username, db_password, instance_id) + + # 验证调用了修改插件的 SQL + alter_calls = [call for call in mock_execute_sql_root.call_args_list + if "ALTER USER" in call.args[0]] + assert len(alter_calls) == 2 # % 和 localhost 各一次 \ No newline at end of file diff --git a/src/backend/tests/service/test_rag_effect.py b/src/backend/tests/service/test_rag_effect.py new file mode 100644 index 0000000..582ef07 --- /dev/null +++ b/src/backend/tests/service/test_rag_effect.py @@ -0,0 +1,521 @@ +""" +RAG 效果测试脚本。 + +测试 RAG 检索的实际效果,包括: +1. 领域知识检索 - 测试业务术语匹配准确度 +2. DDL 检索 - 测试表结构匹配准确度 +3. 历史对话检索 - 测试相似问题匹配 + +运行方式: + cd src/backend + python -m tests.service.test_rag_effect +""" + +import asyncio +import sys +import os + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from app.service.embedding_service import embedding_service +from app.service.vector_db_service import vector_db_service, VectorDBService +from app.service.rag_service import rag_service, RAGContext + + +# ========================================================= +# 测试数据 +# ========================================================= + +# 模拟的领域知识(业务术语) +MOCK_KNOWLEDGE = [ + {"id": 1, "term": "VIP会员", "definition": "消费金额累计超过10000元的用户,享受9折优惠和优先客服"}, + {"id": 2, "term": "活跃用户", "definition": "最近30天内有登录或下单记录的用户"}, + {"id": 3, "term": "流失用户", "definition": "超过90天没有任何活动记录的用户"}, + {"id": 4, "term": "新用户", "definition": "注册时间在7天内的用户,首单享受8折优惠"}, + {"id": 5, "term": "订单状态", "definition": "订单的生命周期状态,包括:待支付、已支付、已发货、已完成、已取消、已退款"}, + {"id": 6, "term": "GMV", "definition": "Gross Merchandise Volume,成交总额,指平台在一定时间内的总销售金额"}, + {"id": 7, "term": "客单价", "definition": "每位顾客平均购买金额,计算方式为总销售额除以订单数"}, + {"id": 8, "term": "复购率", "definition": "在一定时间内再次购买的用户占总用户的比例"}, + {"id": 9, "term": "退款率", "definition": "退款订单数占总订单数的比例,反映商品质量和服务水平"}, + {"id": 10, "term": "库存周转率", "definition": "一定时期内库存周转次数,计算方式为销售成本除以平均库存"}, +] + +# 模拟的 DDL(表结构) +MOCK_DDL = """ +CREATE TABLE users ( + id INT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + phone VARCHAR(20), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login_at TIMESTAMP, + is_vip BOOLEAN DEFAULT FALSE, + total_spent DECIMAL(10,2) DEFAULT 0, + status ENUM('active', 'inactive', 'banned') DEFAULT 'active' +); + +CREATE TABLE products ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(200) NOT NULL, + description TEXT, + price DECIMAL(10,2) NOT NULL, + cost DECIMAL(10,2), + category_id INT, + stock_quantity INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE +); + +CREATE TABLE orders ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + order_no VARCHAR(50) NOT NULL UNIQUE, + total_amount DECIMAL(10,2) NOT NULL, + status ENUM('pending', 'paid', 'shipped', 'completed', 'cancelled', 'refunded') DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + paid_at TIMESTAMP, + shipped_at TIMESTAMP, + completed_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE order_items ( + id INT PRIMARY KEY AUTO_INCREMENT, + order_id INT NOT NULL, + product_id INT NOT NULL, + quantity INT NOT NULL, + unit_price DECIMAL(10,2) NOT NULL, + FOREIGN KEY (order_id) REFERENCES orders(id), + FOREIGN KEY (product_id) REFERENCES products(id) +); + +CREATE TABLE categories ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + parent_id INT, + level INT DEFAULT 1, + FOREIGN KEY (parent_id) REFERENCES categories(id) +); + +CREATE TABLE inventory_logs ( + id INT PRIMARY KEY AUTO_INCREMENT, + product_id INT NOT NULL, + change_quantity INT NOT NULL, + change_type ENUM('in', 'out', 'adjust') NOT NULL, + reason VARCHAR(200), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id) REFERENCES products(id) +); + +CREATE TABLE refunds ( + id INT PRIMARY KEY AUTO_INCREMENT, + order_id INT NOT NULL, + amount DECIMAL(10,2) NOT NULL, + reason TEXT, + status ENUM('pending', 'approved', 'rejected', 'completed') DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + processed_at TIMESTAMP, + FOREIGN KEY (order_id) REFERENCES orders(id) +); +""" + +# 模拟的历史对话 +MOCK_HISTORY = [ + {"id": 1, "role": "user", "content": "查询所有VIP用户的订单总金额"}, + {"id": 2, "role": "assistant", "content": "SELECT u.username, SUM(o.total_amount) as total FROM users u JOIN orders o ON u.id = o.user_id WHERE u.is_vip = TRUE GROUP BY u.id"}, + {"id": 3, "role": "user", "content": "统计每个月的销售额"}, + {"id": 4, "role": "assistant", "content": "SELECT DATE_FORMAT(created_at, '%Y-%m') as month, SUM(total_amount) as monthly_sales FROM orders WHERE status = 'completed' GROUP BY month"}, + {"id": 5, "role": "user", "content": "找出库存少于10的商品"}, + {"id": 6, "role": "assistant", "content": "SELECT * FROM products WHERE stock_quantity < 10 AND is_active = TRUE"}, + {"id": 7, "role": "user", "content": "计算用户的复购率"}, + {"id": 8, "role": "assistant", "content": "SELECT COUNT(DISTINCT CASE WHEN order_count > 1 THEN user_id END) / COUNT(DISTINCT user_id) as repurchase_rate FROM (SELECT user_id, COUNT(*) as order_count FROM orders GROUP BY user_id) t"}, +] + +# 测试查询和期望结果 +TEST_QUERIES = [ + { + "query": "查询VIP用户的消费情况", + "expected_knowledge": ["VIP会员"], + "expected_tables": ["users", "orders"], + "description": "应该匹配VIP会员定义和用户、订单表" + }, + { + "query": "统计这个月的GMV", + "expected_knowledge": ["GMV"], + "expected_tables": ["orders"], + "description": "应该匹配GMV定义和订单表" + }, + { + "query": "找出流失的客户", + "expected_knowledge": ["流失用户"], + "expected_tables": ["users"], + "description": "应该匹配流失用户定义和用户表" + }, + { + "query": "库存不足的商品有哪些", + "expected_knowledge": ["库存周转率"], + "expected_tables": ["products", "inventory_logs"], + "description": "应该匹配库存相关术语和商品、库存表" + }, + { + "query": "退款订单的统计", + "expected_knowledge": ["退款率", "订单状态"], + "expected_tables": ["orders", "refunds"], + "description": "应该匹配退款相关术语和订单、退款表" + }, + { + "query": "新用户的首单优惠", + "expected_knowledge": ["新用户"], + "expected_tables": ["users", "orders"], + "description": "应该匹配新用户定义" + }, +] + + +# ========================================================= +# 测试函数 +# ========================================================= + +PROJECT_ID = 99999 # 测试用项目ID +SESSION_ID = 88888 # 测试用会话ID + + +async def setup_test_data(): + """准备测试数据:将知识、DDL、历史写入向量数据库""" + print("=" * 70) + print("📦 准备测试数据") + print("=" * 70) + + # 1. 索引领域知识 + print("\n1️⃣ 索引领域知识...") + for k in MOCK_KNOWLEDGE: + await rag_service.index_knowledge( + project_id=PROJECT_ID, + knowledge_id=k["id"], + term=k["term"], + definition=k["definition"] + ) + print(f" ✅ 已索引 {len(MOCK_KNOWLEDGE)} 条领域知识") + + # 2. 索引 DDL + print("\n2️⃣ 索引 DDL(表结构)...") + await rag_service.index_ddl( + project_id=PROJECT_ID, + ddl_text=MOCK_DDL + ) + print(f" ✅ 已索引 DDL") + + # 3. 索引历史对话 + print("\n3️⃣ 索引历史对话...") + for msg in MOCK_HISTORY: + await rag_service.index_message( + session_id=SESSION_ID, + message_id=msg["id"], + content=msg["content"], + role=msg["role"] + ) + print(f" ✅ 已索引 {len(MOCK_HISTORY)} 条历史消息") + + print("\n" + "=" * 70) + + +async def test_knowledge_retrieval(): + """测试领域知识检索效果""" + print("\n" + "=" * 70) + print("🔍 测试1: 领域知识检索") + print("=" * 70) + + passed = 0 + failed = 0 + + for i, test in enumerate(TEST_QUERIES, 1): + query = test["query"] + expected = test["expected_knowledge"] + + print(f"\n📝 测试 {i}: {query}") + print(f" 期望匹配: {expected}") + + # 检索 + context = await rag_service.retrieve_context( + query=query, + project_id=PROJECT_ID, + enable_history=False, + enable_knowledge=True, + enable_ddl=False, + knowledge_top_k=3 + ) + + # 提取检索到的术语 + retrieved_terms = [ + r.get("metadata", {}).get("term", "") + for r in context.relevant_knowledge + ] + + print(f" 实际检索: {retrieved_terms}") + + # 检查是否匹配 + matched = any(exp in retrieved_terms for exp in expected) + if matched: + print(f" ✅ 通过") + passed += 1 + else: + print(f" ❌ 未匹配到期望术语") + failed += 1 + + print(f"\n📊 领域知识检索结果: {passed}/{passed+failed} 通过") + return passed, failed + + +async def test_ddl_retrieval(): + """测试 DDL 检索效果""" + print("\n" + "=" * 70) + print("🔍 测试2: DDL(表结构)检索") + print("=" * 70) + + passed = 0 + failed = 0 + + for i, test in enumerate(TEST_QUERIES, 1): + query = test["query"] + expected = test["expected_tables"] + + print(f"\n📝 测试 {i}: {query}") + print(f" 期望表: {expected}") + + # 检索 + context = await rag_service.retrieve_context( + query=query, + project_id=PROJECT_ID, + enable_history=False, + enable_knowledge=False, + enable_ddl=True, + ddl_top_k=3 + ) + + # 提取检索到的表名 + retrieved_tables = [ + r.get("metadata", {}).get("table_name", "") + for r in context.relevant_ddl + ] + + print(f" 实际检索: {retrieved_tables}") + + # 检查是否匹配 + matched = any(exp in retrieved_tables for exp in expected) + if matched: + print(f" ✅ 通过") + passed += 1 + else: + print(f" ❌ 未匹配到期望表") + failed += 1 + + print(f"\n📊 DDL检索结果: {passed}/{passed+failed} 通过") + return passed, failed + + +async def test_history_retrieval(): + """测试历史对话检索效果""" + print("\n" + "=" * 70) + print("🔍 测试3: 历史对话检索") + print("=" * 70) + + test_queries = [ + {"query": "VIP客户的订单", "expected_contains": "VIP"}, + {"query": "每月销售统计", "expected_contains": "月"}, + {"query": "库存预警", "expected_contains": "库存"}, + {"query": "用户复购分析", "expected_contains": "复购"}, + ] + + passed = 0 + failed = 0 + + for i, test in enumerate(test_queries, 1): + query = test["query"] + expected = test["expected_contains"] + + print(f"\n📝 测试 {i}: {query}") + print(f" 期望包含: '{expected}'") + + # 检索 + context = await rag_service.retrieve_context( + query=query, + project_id=PROJECT_ID, + session_id=SESSION_ID, + enable_history=True, + enable_knowledge=False, + enable_ddl=False, + history_top_k=2 + ) + + # 显示检索结果 + for j, h in enumerate(context.relevant_history[:2], 1): + content = h.get("content", "")[:60] + distance = h.get("distance", 0) + print(f" [{j}] (距离:{distance:.3f}) {content}...") + + # 检查是否匹配 + matched = any( + expected in h.get("content", "") + for h in context.relevant_history + ) + + if matched: + print(f" ✅ 通过") + passed += 1 + else: + print(f" ❌ 未匹配") + failed += 1 + + print(f"\n📊 历史检索结果: {passed}/{passed+failed} 通过") + return passed, failed + + +async def test_combined_retrieval(): + """测试综合检索(模拟实际使用场景)""" + print("\n" + "=" * 70) + print("🔍 测试4: 综合检索(实际场景模拟)") + print("=" * 70) + + query = "帮我查询所有VIP用户上个月的订单金额,按金额降序排列" + + print(f"\n📝 用户查询: {query}") + + context = await rag_service.retrieve_context( + query=query, + project_id=PROJECT_ID, + session_id=SESSION_ID, + enable_history=True, + enable_knowledge=True, + enable_ddl=True, + history_top_k=3, + knowledge_top_k=3, + ddl_top_k=5 + ) + + print("\n📚 检索到的领域知识:") + for k in context.relevant_knowledge: + term = k.get("metadata", {}).get("term", "") + dist = k.get("distance", 0) + print(f" - {term} (距离: {dist:.3f})") + + print("\n📋 检索到的相关表:") + for d in context.relevant_ddl: + table = d.get("metadata", {}).get("table_name", "") + dist = d.get("distance", 0) + print(f" - {table} (距离: {dist:.3f})") + + print("\n💬 检索到的历史对话:") + for h in context.relevant_history: + content = h.get("content", "")[:50] + role = h.get("metadata", {}).get("role", "") + dist = h.get("distance", 0) + print(f" - [{role}] {content}... (距离: {dist:.3f})") + + # 构建 Prompt 示例 + print("\n" + "-" * 50) + print("💡 构建的 RAG Prompt 示例:") + print("-" * 50) + + prompt_parts = [] + + if context.relevant_knowledge: + prompt_parts.append("【业务术语说明】") + for k in context.relevant_knowledge: + term = k.get("metadata", {}).get("term", "") + content = k.get("content", "") + prompt_parts.append(f"- {content}") + + if context.relevant_ddl: + prompt_parts.append("\n【相关表结构】") + for d in context.relevant_ddl[:3]: # 只取前3个 + content = d.get("content", "") + prompt_parts.append(content) + + if context.relevant_history: + prompt_parts.append("\n【相关历史问答】") + for h in context.relevant_history: + role = h.get("metadata", {}).get("role", "") + content = h.get("content", "") + prompt_parts.append(f"[{role}]: {content}") + + prompt_parts.append(f"\n【用户问题】\n{query}") + + full_prompt = "\n".join(prompt_parts) + print(full_prompt[:1500] + "..." if len(full_prompt) > 1500 else full_prompt) + + +async def cleanup_test_data(): + """清理测试数据""" + print("\n" + "=" * 70) + print("🧹 清理测试数据") + print("=" * 70) + + try: + await rag_service.delete_project_knowledge(PROJECT_ID) + print(" ✅ 已删除领域知识") + except Exception as e: + print(f" ⚠️ 删除领域知识失败: {e}") + + try: + await rag_service.delete_project_ddl(PROJECT_ID) + print(" ✅ 已删除 DDL") + except Exception as e: + print(f" ⚠️ 删除 DDL 失败: {e}") + + try: + await rag_service.delete_session_history(SESSION_ID) + print(" ✅ 已删除历史对话") + except Exception as e: + print(f" ⚠️ 删除历史对话失败: {e}") + + +async def main(): + """主测试函数""" + print("\n" + "🚀" * 35) + print(" RAG 效果测试") + print("🚀" * 35) + + try: + # 准备数据 + await setup_test_data() + + # 等待向量数据库索引完成 + await asyncio.sleep(1) + + # 运行测试 + k_passed, k_failed = await test_knowledge_retrieval() + d_passed, d_failed = await test_ddl_retrieval() + h_passed, h_failed = await test_history_retrieval() + + # 综合测试 + await test_combined_retrieval() + + # 汇总结果 + total_passed = k_passed + d_passed + h_passed + total_failed = k_failed + d_failed + h_failed + + print("\n" + "=" * 70) + print("📊 测试汇总") + print("=" * 70) + print(f" 领域知识检索: {k_passed}/{k_passed+k_failed} 通过") + print(f" DDL 检索: {d_passed}/{d_passed+d_failed} 通过") + print(f" 历史对话检索: {h_passed}/{h_passed+h_failed} 通过") + print(f" ---") + print(f" 总计: {total_passed}/{total_passed+total_failed} 通过") + + if total_failed == 0: + print("\n 🎉 所有测试通过!RAG 检索效果良好") + else: + print(f"\n ⚠️ 有 {total_failed} 个测试未通过,可能需要调整相似度阈值") + + finally: + # 清理数据 + await cleanup_test_data() + + print("\n" + "=" * 70) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/backend/tests/service/test_rag_service.py b/src/backend/tests/service/test_rag_service.py new file mode 100644 index 0000000..e49a14b --- /dev/null +++ b/src/backend/tests/service/test_rag_service.py @@ -0,0 +1,155 @@ +""" +RAG 服务测试示例。 + +演示如何使用 Embedding 和 RAG 服务。 + +运行方式: + cd src/backend + python -m tests.service.test_rag_service +""" + +import asyncio +import sys +import os + +# 添加 app 目录到 Python 路径 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'app')) + +from service.embedding_service import embedding_service, get_embedding, get_embeddings +from service.vector_db_service import vector_db_service, VectorDBService +from service.rag_service import rag_service, retrieve_chat_context + + +async def test_embedding(): + """测试 Embedding 服务""" + print("=" * 50) + print("测试 Embedding 服务") + print("=" * 50) + + # 单文本向量化 + text = "查询所有订单金额大于1000的用户" + embedding = await get_embedding(text) + print(f"文本: {text}") + print(f"向量维度: {len(embedding)}") + print(f"向量前5个值: {embedding[:5]}") + print() + + # 批量向量化 + texts = [ + "查询用户信息", + "统计订单数量", + "获取商品列表" + ] + embeddings = await get_embeddings(texts) + print(f"批量文本数量: {len(texts)}") + print(f"生成向量数量: {len(embeddings)}") + print() + + +async def test_vector_db(): + """测试向量数据库服务""" + print("=" * 50) + print("测试向量数据库服务") + print("=" * 50) + + # 添加测试文档 + test_docs = [ + "VIP会员: 消费金额超过10000元的用户", + "活跃用户: 最近30天有登录的用户", + "新用户: 注册时间在7天内的用户" + ] + + # 生成向量 + embeddings = await get_embeddings(test_docs) + + # 存入向量数据库 + await vector_db_service.add_documents( + collection_name="test_knowledge", + documents=test_docs, + embeddings=embeddings, + metadatas=[ + {"project_id": 1, "type": "definition"}, + {"project_id": 1, "type": "definition"}, + {"project_id": 1, "type": "definition"} + ], + ids=["k1", "k2", "k3"] + ) + print(f"添加了 {len(test_docs)} 条测试文档") + + # 搜索相似文档 + query = "查询消费金额高的用户" + query_embedding = await get_embedding(query) + + results = await vector_db_service.search( + collection_name="test_knowledge", + query_embedding=query_embedding, + top_k=2, + where={"project_id": 1} + ) + + print(f"\n查询: {query}") + print("搜索结果:") + for i, doc in enumerate(results["documents"]): + distance = results["distances"][i] + print(f" {i+1}. (距离: {distance:.4f}) {doc}") + print() + + +async def test_rag_service(): + """测试 RAG 服务""" + print("=" * 50) + print("测试 RAG 服务") + print("=" * 50) + + # 索引一些测试数据 + # 领域知识 + await rag_service.index_knowledge( + project_id=999, + knowledge_id=1, + term="VIP会员", + definition="消费金额累计超过10000元的用户,享受9折优惠" + ) + + await rag_service.index_knowledge( + project_id=999, + knowledge_id=2, + term="活跃用户", + definition="最近30天内有下单记录的用户" + ) + + print("已索引 2 条领域知识") + + # 检索测试 + context = await rag_service.retrieve_context( + query="查询所有VIP会员的订单", + project_id=999, + session_id=None, + enable_history=False, + enable_knowledge=True, + enable_schema=False + ) + + print(f"\n查询: 查询所有VIP会员的订单") + print(f"检索到 {len(context.relevant_knowledge)} 条相关知识:") + for item in context.relevant_knowledge: + print(f" - {item['content']} (距离: {item['distance']:.4f})") + print() + + +async def main(): + """主测试函数""" + try: + await test_embedding() + await test_vector_db() + await test_rag_service() + print("=" * 50) + print("所有测试完成!") + print("=" * 50) + except Exception as e: + print(f"测试失败: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/backend/tests/service/test_rag_standalone.py b/src/backend/tests/service/test_rag_standalone.py new file mode 100644 index 0000000..af6e453 --- /dev/null +++ b/src/backend/tests/service/test_rag_standalone.py @@ -0,0 +1,235 @@ +""" +RAG 功能独立测试脚本。 + +这个脚本可以直接运行,测试 Embedding 和 ChromaDB 是否正常工作。 + +运行方式: + cd src/backend + python -m tests.service.test_rag_standalone +""" + +import asyncio +import sys +import os + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from openai import AsyncOpenAI +import chromadb +from chromadb.config import Settings +from app.config import settings + +# 从配置文件获取 API key +EMBEDDING_API_KEY = settings.embedding.api_key +EMBEDDING_BASE_URL = settings.embedding.base_url +EMBEDDING_MODEL = settings.embedding.model +EMBEDDING_DIMENSIONS = settings.embedding.dimensions + + +# ========================================================= +# 1. 测试 Embedding 服务(阿里云百炼) +# ========================================================= + +async def test_embedding(): + """测试 Embedding API 是否可用""" + print("=" * 60) + print("1. 测试 Embedding 服务(阿里云百炼 text-embedding-v4)") + print("=" * 60) + + client = AsyncOpenAI( + api_key=EMBEDDING_API_KEY, + base_url=EMBEDDING_BASE_URL + ) + + test_text = "查询所有订单金额大于1000的用户" + + try: + response = await client.embeddings.create( + model=EMBEDDING_MODEL, + input=test_text, + dimensions=EMBEDDING_DIMENSIONS, + encoding_format="float" + ) + + embedding = response.data[0].embedding + print(f"✅ Embedding 服务正常") + print(f" 输入文本: {test_text}") + print(f" 向量维度: {len(embedding)}") + print(f" 向量示例: [{embedding[0]:.6f}, {embedding[1]:.6f}, ...]") + return True + + except Exception as e: + print(f"❌ Embedding 服务失败: {e}") + return False + + +# ========================================================= +# 2. 测试 ChromaDB(内嵌模式,无需 Docker) +# ========================================================= + +def test_chromadb(): + """测试 ChromaDB 向量数据库""" + print("\n" + "=" * 60) + print("2. 测试 ChromaDB 向量数据库(内嵌模式)") + print("=" * 60) + + try: + # 创建内嵌模式客户端(数据存在内存,不持久化) + client = chromadb.Client() + + # 创建或获取集合 + collection = client.get_or_create_collection( + name="test_collection", + metadata={"hnsw:space": "cosine"} + ) + + print(f"✅ ChromaDB 客户端创建成功") + print(f" 模式: 内嵌模式(Embedded)") + print(f" 无需 Docker 容器") + + # 添加测试数据 + collection.add( + documents=["这是测试文档1", "这是测试文档2", "这是测试文档3"], + metadatas=[{"type": "test"}, {"type": "test"}, {"type": "test"}], + ids=["doc1", "doc2", "doc3"] + ) + + print(f" 添加文档数: 3") + print(f" 集合文档总数: {collection.count()}") + + return True + + except Exception as e: + print(f"❌ ChromaDB 测试失败: {e}") + return False + + +# ========================================================= +# 3. 测试完整 RAG 流程(Embedding + ChromaDB) +# ========================================================= + +async def test_full_rag(): + """测试完整的 RAG 流程""" + print("\n" + "=" * 60) + print("3. 测试完整 RAG 流程") + print("=" * 60) + + client = AsyncOpenAI( + api_key=EMBEDDING_API_KEY, + base_url=EMBEDDING_BASE_URL + ) + + # 准备测试知识库 + knowledge_base = [ + {"term": "VIP会员", "definition": "消费金额累计超过10000元的用户,享受9折优惠"}, + {"term": "活跃用户", "definition": "最近30天内有下单记录的用户"}, + {"term": "新用户", "definition": "注册时间在7天内的用户"}, + {"term": "订单状态", "definition": "包括待支付、已支付、已发货、已完成、已取消"}, + {"term": "退款", "definition": "用户申请退还已支付的订单金额"}, + ] + + try: + # 1. 生成知识库向量 + print(" 步骤1: 生成知识库向量...") + texts = [f"{k['term']}: {k['definition']}" for k in knowledge_base] + + response = await client.embeddings.create( + model=EMBEDDING_MODEL, + input=texts, + dimensions=EMBEDDING_DIMENSIONS, + encoding_format="float" + ) + + embeddings = [item.embedding for item in sorted(response.data, key=lambda x: x.index)] + print(f" ✅ 生成 {len(embeddings)} 条知识向量") + + # 2. 存入 ChromaDB + print(" 步骤2: 存入 ChromaDB...") + chroma_client = chromadb.Client() + collection = chroma_client.get_or_create_collection( + name="rag_test", + metadata={"hnsw:space": "cosine"} + ) + + collection.add( + documents=texts, + embeddings=embeddings, + metadatas=[{"term": k["term"]} for k in knowledge_base], + ids=[f"k{i}" for i in range(len(knowledge_base))] + ) + print(f" ✅ 存入 {collection.count()} 条记录") + + # 3. 测试查询 + print(" 步骤3: 测试语义检索...") + query = "查询消费金额高的用户" + + # 生成查询向量 + query_response = await client.embeddings.create( + model=EMBEDDING_MODEL, + input=query, + dimensions=EMBEDDING_DIMENSIONS, + encoding_format="float" + ) + query_embedding = query_response.data[0].embedding + + # 检索 + results = collection.query( + query_embeddings=[query_embedding], + n_results=3, + include=["documents", "metadatas", "distances"] + ) + + print(f"\n 查询: \"{query}\"") + print(f" 检索结果 (Top 3):") + for i, doc in enumerate(results["documents"][0]): + distance = results["distances"][0][i] + similarity = 1 - distance # 余弦距离转相似度 + print(f" {i+1}. [相似度: {similarity:.2%}] {doc}") + + print(f"\n✅ RAG 流程测试成功!") + return True + + except Exception as e: + print(f"❌ RAG 测试失败: {e}") + import traceback + traceback.print_exc() + return False + + +# ========================================================= +# 主函数 +# ========================================================= + +async def main(): + print("\n" + "🚀 RAG 功能测试开始 " + "=" * 40 + "\n") + + results = [] + + # 测试 1: Embedding + results.append(("Embedding 服务", await test_embedding())) + + # 测试 2: ChromaDB + results.append(("ChromaDB", test_chromadb())) + + # 测试 3: 完整 RAG + results.append(("完整 RAG 流程", await test_full_rag())) + + # 汇总结果 + print("\n" + "=" * 60) + print("测试结果汇总") + print("=" * 60) + + all_passed = True + for name, passed in results: + status = "✅ 通过" if passed else "❌ 失败" + print(f" {name}: {status}") + if not passed: + all_passed = False + + print("\n" + ("🎉 所有测试通过!RAG 功能可正常使用。" if all_passed else "⚠️ 部分测试失败,请检查配置。")) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/backend/tests/test_uvicorn_startup.py b/src/backend/tests/test_uvicorn_startup.py new file mode 100644 index 0000000..3f56f88 --- /dev/null +++ b/src/backend/tests/test_uvicorn_startup.py @@ -0,0 +1,24 @@ +import uvicorn +import os +import sys +from app.server import my_app +from app.core.config import config + +print(f"Process ID: {os.getpid()}") +print(f"UVicorn configuration:") +print(f"- Host: {config.app.host}") +print(f"- Port: {config.app.port}") +print(f"- Reload: {config.app.reload}") +print(f"- App: {config.app.uvicorn}") + +# 直接启动应用而不使用uvicorn.run() +if __name__ == "__main__": + # 使用uvicorn命令行参数启动 + sys.argv = [ + "uvicorn", + config.app.uvicorn, + "--host", config.app.host, + "--port", str(config.app.port), + "--reload" + ] + uvicorn.main() diff --git a/src/backend/tests/text2sql/llm_base/__init__.py b/src/backend/tests/text2sql/llm_base/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/backend/tests/text2sql/llm_base/__init__.py @@ -0,0 +1 @@ + diff --git a/src/backend/tests/text2sql/llm_base/adapter.py b/src/backend/tests/text2sql/llm_base/adapter.py new file mode 100644 index 0000000..322ad44 --- /dev/null +++ b/src/backend/tests/text2sql/llm_base/adapter.py @@ -0,0 +1,116 @@ +import os +from typing import TYPE_CHECKING + +import torch +from peft import LoraConfig, PeftModel, TaskType, get_peft_model +from peft.utils import CONFIG_NAME, WEIGHTS_NAME + +from .config_parser import load_trainable_params +from .loggings import get_logger + +if TYPE_CHECKING: + from transformers.modeling_utils import PreTrainedModel + +logger = get_logger(__name__) + + +def init_adapter( + model: "PreTrainedModel", + model_args: "ModelArguments", + finetuning_args: "FinetuningArguments", + is_trainable: bool, + is_mergeable: bool, +) -> "PreTrainedModel": + r""" + Initializes the adapters. + + Support full-parameter, freeze and LoRA ,QLoRA,training. + + Note that the trainable parameters must be cast to float32. + """ + + if finetuning_args.finetuning_type == "none" and is_trainable: + raise ValueError("You cannot use finetuning_type=none while training.") + + if finetuning_args.finetuning_type == "full" and is_trainable: + logger.info("Fine-tuning method: Full") + model = model.float() + + if finetuning_args.finetuning_type == "freeze": + logger.info("Fine-tuning method: Freeze") + + for name, param in model.named_parameters(): + if not any( + trainable_layer in name + for trainable_layer in finetuning_args.trainable_layers + ): + param.requires_grad_(False) + else: + param.data = param.data.to(torch.float32) + + if model_args.checkpoint_dir is not None: + assert load_trainable_params( + model, model_args.checkpoint_dir[0] + ), "Model checkpoint is not correctly loaded." + + if finetuning_args.finetuning_type == "lora": + logger.info("Fine-tuning method: LoRA") + latest_checkpoint = None + + if model_args.checkpoint_dir is not None: + assert os.path.exists( + os.path.join(model_args.checkpoint_dir[0], WEIGHTS_NAME) + ), "Provided path ({}) does not contain a LoRA weight.".format( + model_args.checkpoint_dir[0] + ) + assert os.path.exists( + os.path.join(model_args.checkpoint_dir[0], CONFIG_NAME) + ), "The given checkpoint may be not a LoRA checkpoint, please specify `--finetuning_type full/freeze` instead." + + if (is_trainable and finetuning_args.resume_lora_training) or ( + not is_mergeable + ): # continually fine-tuning + checkpoints_to_merge, latest_checkpoint = ( + model_args.checkpoint_dir[:-1], + model_args.checkpoint_dir[-1], + ) + else: + checkpoints_to_merge = model_args.checkpoint_dir + + for checkpoint in checkpoints_to_merge: + model = PeftModel.from_pretrained(model, checkpoint) + model = model.merge_and_unload() + + if len(checkpoints_to_merge) > 0: + logger.info( + "Merged {} model checkpoint(s).".format(len(checkpoints_to_merge)) + ) + + if ( + latest_checkpoint is not None + ): # resume lora training or quantized inference + model = PeftModel.from_pretrained( + model, latest_checkpoint, is_trainable=is_trainable + ) + + if ( + is_trainable and latest_checkpoint is None + ): # create new lora weights while training + lora_config = LoraConfig( + task_type=TaskType.CAUSAL_LM, + inference_mode=False, + r=finetuning_args.lora_rank, + lora_alpha=finetuning_args.lora_alpha, + lora_dropout=finetuning_args.lora_dropout, + target_modules=finetuning_args.lora_target, + ) + model = get_peft_model(model, lora_config) + + if model_args.checkpoint_dir is not None: + logger.info( + "Loaded fine-tuned model from checkpoint(s): {}".format( + ",".join(model_args.checkpoint_dir) + ) + ) + + return model diff --git a/src/backend/tests/text2sql/llm_base/chat_model.py b/src/backend/tests/text2sql/llm_base/chat_model.py new file mode 100644 index 0000000..82b0e0b --- /dev/null +++ b/src/backend/tests/text2sql/llm_base/chat_model.py @@ -0,0 +1,125 @@ +from threading import Thread +from typing import Any, Dict, Generator, List, Optional, Tuple + +import torch +from transformers import GenerationConfig, TextIteratorStreamer + +from ..data_process.data_utils import get_template_and_fix_tokenizer +from .config_parser import get_infer_args +from .load_tokenizer import dispatch_model, load_model_and_tokenizer +from .model_trainer import get_logits_processor + + +class ChatModel: + def __init__(self, args: Optional[Dict[str, Any]] = None) -> None: + ( + model_args, + self.data_args, + finetuning_args, + self.generating_args, + ) = get_infer_args(args) + self.model, self.tokenizer = load_model_and_tokenizer( + model_args, finetuning_args + ) + self.tokenizer.padding_side = "left" + self.model = dispatch_model(self.model) + self.template = get_template_and_fix_tokenizer( + self.data_args.template, self.tokenizer + ) + self.system_prompt = self.data_args.system_prompt + + def process_args( + self, + query: str, + history: Optional[List[Tuple[str, str]]] = None, + system: Optional[str] = None, + **input_kwargs + ) -> Tuple[Dict[str, Any], int]: + system = system or self.system_prompt + + prompt, _ = self.template.encode_oneturn( + tokenizer=self.tokenizer, + query=query, + resp="", + history=history, + system=system, + ) + input_ids = torch.tensor([prompt], device=self.model.device) + prompt_length = len(input_ids[0]) + + do_sample = input_kwargs.pop("do_sample", None) + temperature = input_kwargs.pop("temperature", None) + top_p = input_kwargs.pop("top_p", None) + top_k = input_kwargs.pop("top_k", None) + repetition_penalty = input_kwargs.pop("repetition_penalty", None) + max_length = input_kwargs.pop("max_length", None) + max_new_tokens = input_kwargs.pop("max_new_tokens", None) + + generating_args = self.generating_args.to_dict() + generating_args.update( + dict( + do_sample=do_sample + if do_sample is not None + else generating_args["do_sample"], + temperature=temperature or generating_args["temperature"], + top_p=top_p or generating_args["top_p"], + top_k=top_k or generating_args["top_k"], + repetition_penalty=repetition_penalty + or generating_args["repetition_penalty"], + eos_token_id=[self.tokenizer.eos_token_id] + + self.tokenizer.additional_special_tokens_ids, + pad_token_id=self.tokenizer.pad_token_id, + ) + ) + + if max_length: + generating_args.pop("max_new_tokens", None) + generating_args["max_length"] = max_length + + if max_new_tokens: + generating_args.pop("max_length", None) + generating_args["max_new_tokens"] = max_new_tokens + + gen_kwargs = dict( + inputs=input_ids, + generation_config=GenerationConfig(**generating_args), + logits_processor=get_logits_processor(), + ) + + return gen_kwargs, prompt_length + + @torch.inference_mode() + def chat( + self, + query: str, + history: Optional[List[Tuple[str, str]]] = None, + system: Optional[str] = None, + **input_kwargs + ) -> Tuple[str, Tuple[int, int]]: + gen_kwargs, prompt_length = self.process_args( + query, history, system, **input_kwargs + ) + generation_output = self.model.generate(**gen_kwargs) + outputs = generation_output.tolist()[0][prompt_length:] + response = self.tokenizer.decode(outputs, skip_special_tokens=True) + response_length = len(outputs) + return response, (prompt_length, response_length) + + @torch.inference_mode() + def stream_chat( + self, + query: str, + history: Optional[List[Tuple[str, str]]] = None, + system: Optional[str] = None, + **input_kwargs + ) -> Generator[str, None, None]: + gen_kwargs, _ = self.process_args(query, history, system, **input_kwargs) + streamer = TextIteratorStreamer( + self.tokenizer, timeout=60.0, skip_prompt=True, skip_special_tokens=True + ) + gen_kwargs["streamer"] = streamer + + thread = Thread(target=self.model.generate, kwargs=gen_kwargs) + thread.start() + + yield from streamer diff --git a/src/backend/tests/text2sql/llm_base/config_parser.py b/src/backend/tests/text2sql/llm_base/config_parser.py new file mode 100644 index 0000000..34ead7a --- /dev/null +++ b/src/backend/tests/text2sql/llm_base/config_parser.py @@ -0,0 +1,258 @@ +import os +import sys +from typing import Any, Dict, Optional, Tuple + +import datasets +import torch +import transformers +from transformers import HfArgumentParser, Seq2SeqTrainingArguments +from transformers.modeling_utils import load_sharded_checkpoint +from transformers.trainer import WEIGHTS_INDEX_NAME, WEIGHTS_NAME +from transformers.trainer_utils import get_last_checkpoint + +from ..configs.data_args import DataArguments +from ..configs.model_args import ( + FinetuningArguments, + GeneratingArguments, + ModelArguments, +) +from .loggings import get_logger + +logger = get_logger(__name__) + + +def get_state_dict(model: torch.nn.Module) -> Dict[str, torch.Tensor]: + state_dict: Dict[str, torch.Tensor] = model.state_dict() + filtered_state_dict = {} + + for k, v in model.named_parameters(): + if v.requires_grad: + filtered_state_dict[k] = state_dict[k].cpu().clone().detach() + + return filtered_state_dict + + +def load_trainable_params(model: torch.nn.Module, checkpoint_dir: os.PathLike) -> bool: + weights_file = os.path.join(checkpoint_dir, WEIGHTS_NAME) + if os.path.exists(weights_file): + model_state_dict = torch.load(weights_file, map_location="cpu") + model.load_state_dict(model_state_dict, strict=False) # skip missing keys + elif os.path.exists(os.path.join(checkpoint_dir, WEIGHTS_INDEX_NAME)): + load_sharded_checkpoint(model, checkpoint_dir, strict=False) + else: + logger.warning( + "Provided path ({}) does not contain pre-trained weights.".format( + checkpoint_dir + ) + ) + return False + return True + + +def _parse_args( + parser: HfArgumentParser, args: Optional[Dict[str, Any]] = None +) -> Tuple[Any]: + if args is not None: + return parser.parse_dict(args) + elif len(sys.argv) == 2 and sys.argv[1].endswith(".yaml"): + return parser.parse_yaml_file(os.path.abspath(sys.argv[1])) + elif len(sys.argv) == 2 and sys.argv[1].endswith(".json"): + return parser.parse_json_file(os.path.abspath(sys.argv[1])) + else: + return parser.parse_args_into_dataclasses() + + +def parse_train_args( + args: Optional[Dict[str, Any]] = None +) -> Tuple[ + ModelArguments, + DataArguments, + Seq2SeqTrainingArguments, + FinetuningArguments, + GeneratingArguments, +]: + parser = HfArgumentParser( + ( + ModelArguments, + DataArguments, + Seq2SeqTrainingArguments, + FinetuningArguments, + GeneratingArguments, + ) + ) + return _parse_args(parser, args) + + +def parse_infer_args( + args: Optional[Dict[str, Any]] = None +) -> Tuple[ModelArguments, DataArguments, FinetuningArguments, GeneratingArguments]: + parser = HfArgumentParser( + (ModelArguments, DataArguments, FinetuningArguments, GeneratingArguments) + ) + return _parse_args(parser, args) + + +def get_train_args( + args: Optional[Dict[str, Any]] = None, data_args_init: bool = True +) -> Tuple[ + ModelArguments, + DataArguments, + Seq2SeqTrainingArguments, + FinetuningArguments, + GeneratingArguments, +]: + ( + model_args, + data_args, + training_args, + finetuning_args, + generating_args, + ) = parse_train_args(args) + + # Setup logging + if training_args.should_log: + # The default of training_args.log_level is passive, so we set log level at info here to have that default. + transformers.utils.logging.set_verbosity_info() + + log_level = training_args.get_process_log_level() + datasets.utils.logging.set_verbosity(log_level) + transformers.utils.logging.set_verbosity(log_level) + transformers.utils.logging.enable_default_handler() + transformers.utils.logging.enable_explicit_format() + + # Check arguments (do not check finetuning_args since it may be loaded from checkpoints) + if data_args_init: + data_args.init_for_training() + + if training_args.max_steps == -1 and data_args.streaming: + raise ValueError("Please specify `max_steps` in streaming mode.") + + if data_args.val_size > 1e-6 and data_args.val_size < 1 and data_args.streaming: + raise ValueError("Streaming mode should have an integer val size.") + + if training_args.do_train and training_args.predict_with_generate: + raise ValueError( + "`predict_with_generate` cannot be set as True while training." + ) + + if ( + training_args.do_train + and finetuning_args.finetuning_type == "lora" + and finetuning_args.lora_target is None + ): + raise ValueError("Please specify `lora_target` in LoRA training.") + + if ( + model_args.quantization_bit is not None + and finetuning_args.finetuning_type != "lora" + ): + raise ValueError("Quantization is only compatible with the LoRA method.") + + if model_args.checkpoint_dir is not None: + if finetuning_args.finetuning_type != "lora": + if len(model_args.checkpoint_dir) != 1: + raise ValueError("Only LoRA tuning accepts multiple checkpoints.") + elif ( + model_args.quantization_bit is not None + and len(model_args.checkpoint_dir) != 1 + ): + raise ValueError("Quantized model only accepts a single checkpoint.") + + if model_args.quantization_bit is not None and (not training_args.do_train): + logger.warning("Evaluating model in 4/8-bit mode may cause lower scores.") + + if training_args.do_train and (not training_args.fp16) and (not training_args.bf16): + logger.warning("We recommend enable mixed precision training.") + + # postprocess data_args + if data_args.max_samples is not None and data_args.streaming: + logger.warning( + "`max_samples` is incompatible with `streaming`. Disabling max_samples." + ) + data_args.max_samples = None + + # postprocess training_args + if ( + training_args.local_rank != -1 + and training_args.ddp_find_unused_parameters is None + and finetuning_args.finetuning_type == "lora" + ): + logger.warning( + "`ddp_find_unused_parameters` needs to be set as False for LoRA in DDP training." + ) + training_args_dict = training_args.to_dict() + training_args_dict.update(dict(ddp_find_unused_parameters=False)) + training_args = Seq2SeqTrainingArguments(**training_args_dict) + + if ( + training_args.resume_from_checkpoint is None + and training_args.do_train + and os.path.isdir(training_args.output_dir) + and not training_args.overwrite_output_dir + ): + last_checkpoint = get_last_checkpoint(training_args.output_dir) + if last_checkpoint is None and len(os.listdir(training_args.output_dir)) > 0: + raise ValueError( + "Output directory already exists and is not empty. Use `overwrite_output_dir`." + ) + + if last_checkpoint is not None: + training_args_dict = training_args.to_dict() + training_args_dict.update(dict(resume_from_checkpoint=last_checkpoint)) + training_args = Seq2SeqTrainingArguments(**training_args_dict) + logger.info( + "Resuming from checkpoint. Change `output_dir` or use `overwrite_output_dir` to avoid." + ) + + # postprocess model_args + if training_args.bf16: + if not torch.cuda.is_bf16_supported(): + raise ValueError("Current device does not support bf16 training.") + model_args.compute_dtype = torch.bfloat16 + else: + model_args.compute_dtype = torch.float16 + + model_args.model_max_length = ( + data_args.max_source_length + data_args.max_target_length + ) + + # Log on each process the small summary: + logger.info( + "Process rank: {}, device: {}, n_gpu: {}\n distributed training: {}, compute dtype: {}".format( + training_args.local_rank, + training_args.device, + training_args.n_gpu, + bool(training_args.local_rank != -1), + str(model_args.compute_dtype), + ) + ) + logger.info(f"Training/evaluation parameters {training_args}") + + # Set seed before initializing model. + transformers.set_seed(training_args.seed) + + return model_args, data_args, training_args, finetuning_args, generating_args + + +def get_infer_args( + args: Optional[Dict[str, Any]] = None +) -> Tuple[ModelArguments, DataArguments, FinetuningArguments, GeneratingArguments]: + model_args, data_args, finetuning_args, generating_args = parse_infer_args(args) + + if ( + model_args.quantization_bit is not None + and finetuning_args.finetuning_type != "lora" + ): + raise ValueError("Quantization is only compatible with the LoRA method.") + + if model_args.checkpoint_dir is not None: + if finetuning_args.finetuning_type != "lora": + if len(model_args.checkpoint_dir) != 1: + raise ValueError("Only LoRA tuning accepts multiple checkpoints.") + elif ( + model_args.quantization_bit is not None + and len(model_args.checkpoint_dir) != 1 + ): + raise ValueError("Quantized model only accepts a single checkpoint.") + + return model_args, data_args, finetuning_args, generating_args diff --git a/src/backend/tests/text2sql/llm_base/load_tokenizer.py b/src/backend/tests/text2sql/llm_base/load_tokenizer.py new file mode 100644 index 0000000..5bd16fc --- /dev/null +++ b/src/backend/tests/text2sql/llm_base/load_tokenizer.py @@ -0,0 +1,401 @@ +import inspect +import math +import os +from types import MethodType +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple + +import torch +from transformers import ( + AutoConfig, + AutoModelForCausalLM, + AutoTokenizer, + BitsAndBytesConfig, + PretrainedConfig, + PreTrainedModel, + PreTrainedTokenizer, + PreTrainedTokenizerBase, +) +from transformers.deepspeed import is_deepspeed_zero3_enabled +from transformers.trainer import SAFE_WEIGHTS_NAME, WEIGHTS_NAME +from transformers.utils import cached_file, check_min_version +from transformers.utils.versions import require_version +from trl import AutoModelForCausalLMWithValueHead + +from ..configs.config import LAYERNORM_NAMES, VALUE_HEAD_FILE_NAME +from ..configs.model_args import FinetuningArguments +from .adapter import init_adapter +from .loggings import get_logger, reset_logging + +if TYPE_CHECKING: + from transformers import PreTrainedTokenizer + + from ..configs.model_args import ModelArguments + + +logger = get_logger(__name__) + + +check_min_version("4.29.1") +require_version("datasets>=2.12.0", "To fix: pip install datasets>=2.12.0") +require_version("accelerate>=0.21.0", "To fix: pip install accelerate>=0.21.0") +require_version("peft>=0.4.0", "To fix: pip install peft>=0.4.0") +require_version("trl>=0.5.0", "To fix: pip install trl>=0.5.0") + + +def count_parameters(model: torch.nn.Module) -> Tuple[int, int]: + r""" + Returns the number of trainable parameters and number of all parameters in the model. + """ + trainable_params, all_param = 0, 0 + for param in model.parameters(): + num_params = param.numel() + # if using DS Zero 3 and the weights are initialized empty + if num_params == 0 and hasattr(param, "ds_numel"): + num_params = param.ds_numel + + # Due to the design of 4bit linear layers from bitsandbytes, multiply the number of parameters by 2 + if param.__class__.__name__ == "Params4bit": + num_params = num_params * 2 + + all_param += num_params + if param.requires_grad: + trainable_params += num_params + + return trainable_params, all_param + + +# Includes: (1) cast the layernorm in fp32 (2) make output embedding layer require grads (3) upcast the lm_head to fp32 +# Inspired by: https://github.com/huggingface/peft/blob/c0209c35abbf88c63aa267800d98a8e212ed0a42/src/peft/utils/other.py#L35 +def prepare_model_for_training( + model: "PreTrainedModel", + finetuning_type: str, + output_layer_name: Optional[str] = "lm_head", + use_gradient_checkpointing: Optional[bool] = True, + layer_norm_names: Optional[List[str]] = LAYERNORM_NAMES, +) -> "PreTrainedModel": + for name, param in model.named_parameters(): + if param.ndim == 1 and any( + layer_norm_name in name for layer_norm_name in layer_norm_names + ): + param.data = param.data.to(torch.float32) + + if use_gradient_checkpointing: + if hasattr(model, "enable_input_require_grads"): + model.enable_input_require_grads() + else: + + def make_inputs_require_grad(module, input, output): + output.requires_grad_(True) + + model.get_input_embeddings().register_forward_hook(make_inputs_require_grad) + + model.gradient_checkpointing_enable() + model.config.use_cache = ( + False # turn off when gradient checkpointing is enabled + ) + + if finetuning_type != "full" and hasattr(model, output_layer_name): + output_layer: torch.nn.Linear = getattr(model, output_layer_name) + input_dtype = output_layer.weight.dtype + + class CastOutputToFloat(torch.nn.Sequential): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return super().forward(x.to(input_dtype)).to(torch.float32) + + setattr(model, output_layer_name, CastOutputToFloat(output_layer)) + + return model + + +def load_valuehead_params( + path_or_repo_id: str, model_args: "ModelArguments" +) -> Dict[str, torch.Tensor]: + r""" + Loads value head parameters from Hugging Face Hub or local disk. + + Returns: dict with keys `v_head.summary.weight` and `v_head.summary.bias`. + """ + kwargs = {"path_or_repo_id": path_or_repo_id, "cache_dir": model_args.cache_dir} + + if "token" in inspect.signature(cached_file).parameters: + kwargs["token"] = model_args.hf_hub_token + elif ( + "use_auth_token" in inspect.signature(cached_file).parameters + ): # for transformers==4.31.0 + kwargs["use_auth_token"] = model_args.hf_hub_token + else: + logger.warning("Ignore `hf_hub_token` since matched parameter is not found.") + + try: + vhead_file = cached_file(filename=WEIGHTS_NAME, **kwargs) + return torch.load(vhead_file, map_location="cpu") + except Exception as err: + logger.info("Failed to load {}: {}".format(WEIGHTS_NAME, str(err))) + + try: + from safetensors import safe_open + + vhead_file = cached_file(filename=SAFE_WEIGHTS_NAME, **kwargs) + with safe_open(vhead_file, framework="pt", device="cpu") as f: + return { + "v_head.summary.weight": f.get_tensor("v_head.summary.weight"), + "v_head.summary.bias": f.get_tensor("v_head.summary.bias"), + } + except Exception as err: + logger.info("Failed to load {}: {}".format(SAFE_WEIGHTS_NAME, str(err))) + + logger.warning( + "Provided path ({}) does not contain valuehead weights.".format(path_or_repo_id) + ) + return None + + +def load_model_and_tokenizer( + model_args: "ModelArguments", + finetuning_args: "FinetuningArguments", + is_trainable: Optional[bool] = False, + add_valuehead: Optional[bool] = False, +) -> Tuple[PreTrainedModel, "PreTrainedTokenizer"]: + r""" + Loads pretrained model and tokenizer. + + Support both training and inference. + """ + if (not is_trainable) and model_args.checkpoint_dir is None: + logger.warning( + "Checkpoint is not found at evaluation, load the original model." + ) + finetuning_args = FinetuningArguments(finetuning_type="none") + + config_kwargs = { + "trust_remote_code": True, + "cache_dir": model_args.cache_dir, + "revision": model_args.model_revision, + "use_auth_token": True if model_args.use_auth_token else None, + } + + tokenizer = AutoTokenizer.from_pretrained( + model_args.model_name_or_path, + use_fast=model_args.use_fast_tokenizer, + split_special_tokens=model_args.split_special_tokens, + padding_side="right", # training with left-padded tensors in fp16 precision may cause overflow + **config_kwargs + ) + + if ( + finetuning_args.finetuning_type == "full" + and model_args.checkpoint_dir is not None + ): + model_to_load = model_args.checkpoint_dir[0] + else: + model_to_load = model_args.model_name_or_path + + config = AutoConfig.from_pretrained(model_to_load, **config_kwargs) + + if hasattr(config, "fp16") and hasattr(config, "bf16"): # fix Qwen config + if model_args.compute_dtype == torch.bfloat16: + setattr(config, "bf16", True) + else: + setattr(config, "fp16", True) + + # Fix config (for Qwen) + if getattr(config, "model_type", None) == "qwen": + for dtype_name, dtype in [ + ("fp16", torch.float16), + ("bf16", torch.bfloat16), + ("fp32", torch.float32), + ]: + setattr(config, dtype_name, getattr(config, "torch_dtype", None) == dtype) + + # Set RoPE scaling + if model_args.rope_scaling is not None: + if hasattr(config, "use_dynamic_ntk"): # for Qwen models + if is_trainable: + logger.warning("Qwen model does not support RoPE scaling in training.") + else: + setattr(config, "use_dynamic_ntk", True) + setattr(config, "use_logn_attn", True) + logger.info("Using dynamic NTK scaling.") + + elif hasattr(config, "rope_scaling"): # for LLaMA models + require_version( + "transformers>=4.31.0", "RoPE scaling requires transformers>=4.31.0" + ) + + if is_trainable: + if model_args.rope_scaling == "dynamic": + logger.warning( + "Dynamic NTK may not work well with fine-tuning. " + "See: https://github.com/huggingface/transformers/pull/24653" + ) + + current_max_length = getattr(config, "max_position_embeddings", None) + if ( + current_max_length + and model_args.model_max_length > current_max_length + ): + scaling_factor = float( + math.ceil(model_args.model_max_length / current_max_length) + ) + else: + logger.warning( + "Input length is smaller than max length. Consider increase input length." + ) + scaling_factor = 1.0 + else: + scaling_factor = 2.0 + + setattr( + config, + "rope_scaling", + {"type": model_args.rope_scaling, "factor": scaling_factor}, + ) + logger.info( + "Using {} scaling strategy and setting scaling factor to {}".format( + model_args.rope_scaling, scaling_factor + ) + ) + + else: + logger.warning("Current model does not support RoPE scaling.") + + # Quantization configurations (using bitsandbytes library). + is_mergeable = True + if model_args.quantization_bit is not None: + if is_deepspeed_zero3_enabled(): + raise ValueError("DeepSpeed ZeRO-3 is incompatible with quantization.") + + if model_args.quantization_bit == 8: + require_version( + "bitsandbytes>=0.37.0", "To fix: pip install bitsandbytes>=0.37.0" + ) + # config_kwargs["load_in_8bit"] = True + config_kwargs["quantization_config"] = BitsAndBytesConfig(load_in_8bit=True) + + elif model_args.quantization_bit == 4: + require_version( + "bitsandbytes>=0.39.0", "To fix: pip install bitsandbytes>=0.39.0" + ) + # config_kwargs["load_in_4bit"] = True + config_kwargs["quantization_config"] = BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_compute_dtype=model_args.compute_dtype, + bnb_4bit_use_double_quant=model_args.double_quantization, + bnb_4bit_quant_type=model_args.quantization_type, + ) + + is_mergeable = False + config_kwargs["device_map"] = ( + {"": int(os.environ.get("LOCAL_RANK", "0"))} if is_trainable else "auto" + ) + logger.info("Quantizing model to {} bit.".format(model_args.quantization_bit)) + + # Load and prepare pre-trained models (without valuehead). + model = AutoModelForCausalLM.from_pretrained( + model_to_load, + config=config, + torch_dtype=model_args.compute_dtype, + low_cpu_mem_usage=(not is_deepspeed_zero3_enabled()), + **config_kwargs + ) + + # Disable custom generate method (for Qwen) + if "GenerationMixin" not in str(model.generate.__func__): + model.generate = MethodType(PreTrainedModel.generate, model) + + # Fix LM head (for ChatGLM2,ChatGLM3) + if not hasattr(model, "lm_head") and hasattr(model, "transformer"): + setattr(model, "lm_head", model.transformer.output_layer) + + # Register auto class to save the custom code files. + if isinstance(config, PretrainedConfig) and "AutoConfig" in getattr( + config, "auto_map", {} + ): + config.__class__.register_for_auto_class() + if isinstance(model, PreTrainedModel) and "AutoModelForCausalLM" in getattr( + config, "auto_map", {} + ): + model.__class__.register_for_auto_class() + if isinstance( + tokenizer, PreTrainedTokenizerBase + ) and "AutoTokenizer" in tokenizer.init_kwargs.get("auto_map", {}): + tokenizer.__class__.register_for_auto_class() + + # Initialize adapters + model = ( + prepare_model_for_training(model, finetuning_args.finetuning_type) + if is_trainable + else model + ) + model = init_adapter(model, model_args, finetuning_args, is_trainable, is_mergeable) + + # Prepare model with valuehead for RLHF + if add_valuehead: + model: "AutoModelForCausalLMWithValueHead" = ( + AutoModelForCausalLMWithValueHead.from_pretrained(model) + ) + ignore_modules = [ + name for name, _ in model.named_parameters() if "pretrained_model" in name + ] + setattr(model, "_keys_to_ignore_on_save", ignore_modules) + setattr( + model, "tie_weights", MethodType(lambda _: None, model) + ) # use empty method + vhead_path = ( + model_args.checkpoint_dir[-1] + if model_args.checkpoint_dir is not None + else model_args.model_name_or_path + ) + vhead_params = load_valuehead_params(vhead_path, model_args) + if vhead_params is not None: + model.load_state_dict(vhead_params, strict=False) + logger.info("Loaded valuehead from checkpoint: {}".format(vhead_path)) + + # Prepare model for inference + if not is_trainable: + model.requires_grad_(False) # fix all model params + infer_dtype = ( + torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16 + ) # detect cuda capability + model = model.to(infer_dtype) if model_args.quantization_bit is None else model + + trainable_params, all_param = count_parameters(model) + logger.info( + "trainable params: {:d} || all params: {:d} || trainable%: {:.4f}".format( + trainable_params, all_param, 100 * trainable_params / all_param + ) + ) + + return model, tokenizer + + +def dispatch_model(model: "PreTrainedModel") -> "PreTrainedModel": + r""" + Dispatches a pre-trained model to GPUs with balanced memory. + Borrowed from: https://github.com/huggingface/transformers/blob/v4.31.0/src/transformers/modeling_utils.py#L2803 + """ + if getattr(model, "is_loaded_in_8bit", False) or getattr( + model, "is_loaded_in_4bit", False + ): # do nothing + return model + + if torch.cuda.device_count() > 1: + from accelerate import dispatch_model + from accelerate.utils import get_balanced_memory, infer_auto_device_map + + if model._no_split_modules is None: + raise ValueError( + "The model class needs to implement the `_no_split_modules` attribute." + ) + + kwargs = { + "dtype": model.dtype, + "no_split_module_classes": model._no_split_modules, + } + max_memory = get_balanced_memory(model, **kwargs) + # Make sure tied weights are tied before creating the device map. + model.tie_weights() + device_map = infer_auto_device_map(model, max_memory=max_memory, **kwargs) + return dispatch_model(model, device_map) + else: + return model.cuda() diff --git a/src/backend/tests/text2sql/llm_base/loggings.py b/src/backend/tests/text2sql/llm_base/loggings.py new file mode 100644 index 0000000..b33270f --- /dev/null +++ b/src/backend/tests/text2sql/llm_base/loggings.py @@ -0,0 +1,229 @@ +import json +import logging +import os +import sys +import time +from datetime import timedelta +from typing import TYPE_CHECKING + +from transformers import TrainerCallback +from transformers.trainer_utils import has_length + +from ..configs.config import LOG_FILE_NAME + +if TYPE_CHECKING: + from transformers import TrainerControl, TrainerState, TrainingArguments + + +def reset_logging(): + r""" + Removes basic config of root logger + """ + root = logging.getLogger() + list(map(root.removeHandler, root.handlers)) + list(map(root.removeFilter, root.filters)) + + +def get_logger(name: str) -> logging.Logger: + formatter = logging.Formatter( + fmt="%(asctime)s - %(levelname)s - %(name)s - %(message)s", + datefmt="%m/%d/%Y %H:%M:%S", + ) + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + logger.addHandler(handler) + + return logger + + +logger = get_logger(__name__) + + +class LoggerHandler(logging.Handler): + def __init__(self): + super().__init__() + self.log = "" + + def reset(self): + self.log = "" + + def emit(self, record): + if record.name == "httpx": + return + log_entry = self.format(record) + self.log += log_entry + self.log += "\n\n" + + +class LogCallback(TrainerCallback): + def __init__(self, runner=None): + self.runner = runner + self.in_training = False + self.start_time = time.time() + self.cur_steps = 0 + self.max_steps = 0 + self.elapsed_time = "" + self.remaining_time = "" + + def timing(self): + cur_time = time.time() + elapsed_time = cur_time - self.start_time + avg_time_per_step = elapsed_time / self.cur_steps if self.cur_steps != 0 else 0 + remaining_time = (self.max_steps - self.cur_steps) * avg_time_per_step + self.elapsed_time = str(timedelta(seconds=int(elapsed_time))) + self.remaining_time = str(timedelta(seconds=int(remaining_time))) + + def on_train_begin( + self, + args: "TrainingArguments", + state: "TrainerState", + control: "TrainerControl", + **kwargs + ): + r""" + Event called at the beginning of training. + """ + if state.is_local_process_zero: + self.in_training = True + self.start_time = time.time() + self.max_steps = state.max_steps + if os.path.exists(os.path.join(args.output_dir, LOG_FILE_NAME)): + logger.warning("Previous log file in this folder will be deleted.") + os.remove(os.path.join(args.output_dir, LOG_FILE_NAME)) + + def on_train_end( + self, + args: "TrainingArguments", + state: "TrainerState", + control: "TrainerControl", + **kwargs + ): + r""" + Event called at the end of training. + """ + if state.is_local_process_zero: + self.in_training = False + self.cur_steps = 0 + self.max_steps = 0 + + def on_substep_end( + self, + args: "TrainingArguments", + state: "TrainerState", + control: "TrainerControl", + **kwargs + ): + r""" + Event called at the end of an substep during gradient accumulation. + """ + if ( + state.is_local_process_zero + and self.runner is not None + and self.runner.aborted + ): + control.should_epoch_stop = True + control.should_training_stop = True + + def on_step_end( + self, + args: "TrainingArguments", + state: "TrainerState", + control: "TrainerControl", + **kwargs + ): + r""" + Event called at the end of a training step. + """ + if state.is_local_process_zero: + self.cur_steps = state.global_step + self.timing() + if self.runner is not None and self.runner.aborted: + control.should_epoch_stop = True + control.should_training_stop = True + + def on_evaluate( + self, + args: "TrainingArguments", + state: "TrainerState", + control: "TrainerControl", + **kwargs + ): + r""" + Event called after an evaluation phase. + """ + if state.is_local_process_zero and not self.in_training: + self.cur_steps = 0 + self.max_steps = 0 + + def on_predict( + self, + args: "TrainingArguments", + state: "TrainerState", + control: "TrainerControl", + *other, + **kwargs + ): + r""" + Event called after a successful prediction. + """ + if state.is_local_process_zero and not self.in_training: + self.cur_steps = 0 + self.max_steps = 0 + + def on_log( + self, + args: "TrainingArguments", + state: "TrainerState", + control: "TrainerControl", + **kwargs + ) -> None: + r""" + Event called after logging the last logs. + """ + if not state.is_local_process_zero: + return + + logs = dict( + current_steps=self.cur_steps, + total_steps=self.max_steps, + loss=state.log_history[-1].get("loss", None), + eval_loss=state.log_history[-1].get("eval_loss", None), + predict_loss=state.log_history[-1].get("predict_loss", None), + reward=state.log_history[-1].get("reward", None), + learning_rate=state.log_history[-1].get("learning_rate", None), + epoch=state.log_history[-1].get("epoch", None), + percentage=round(self.cur_steps / self.max_steps * 100, 2) + if self.max_steps != 0 + else 100, + elapsed_time=self.elapsed_time, + remaining_time=self.remaining_time, + ) + os.makedirs(args.output_dir, exist_ok=True) + with open( + os.path.join(args.output_dir, "trainer_log.jsonl"), "a", encoding="utf-8" + ) as f: + f.write(json.dumps(logs) + "\n") + + def on_prediction_step( + self, + args: "TrainingArguments", + state: "TrainerState", + control: "TrainerControl", + **kwargs + ): + r""" + Event called after a prediction step. + """ + eval_dataloader = kwargs.pop("eval_dataloader", None) + if ( + state.is_local_process_zero + and has_length(eval_dataloader) + and not self.in_training + ): + if self.max_steps == 0: + self.max_steps = len(eval_dataloader) + self.cur_steps += 1 + self.timing() diff --git a/src/backend/tests/text2sql/llm_base/model_trainer.py b/src/backend/tests/text2sql/llm_base/model_trainer.py new file mode 100644 index 0000000..0a56f7f --- /dev/null +++ b/src/backend/tests/text2sql/llm_base/model_trainer.py @@ -0,0 +1,412 @@ +import json +import math +import os +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union + +import jieba +import matplotlib.pyplot as plt +import numpy as np +import torch +import torch.nn as nn +from nltk.translate.bleu_score import SmoothingFunction, sentence_bleu +from peft import PeftModel +from rouge_chinese import Rouge +from transformers import Seq2SeqTrainer +from transformers.generation.logits_process import LogitsProcessor +from transformers.generation.utils import LogitsProcessorList +from transformers.modeling_utils import ( + PreTrainedModel, + load_sharded_checkpoint, + unwrap_model, +) +from transformers.trainer import ( + TRAINER_STATE_NAME, + TRAINING_ARGS_NAME, + WEIGHTS_INDEX_NAME, + WEIGHTS_NAME, +) +from trl import PreTrainedModelWrapper + +from ..configs.config import FINETUNING_ARGS_NAME, IGNORE_INDEX, VALUE_HEAD_FILE_NAME +from .config_parser import get_state_dict, get_train_args, load_trainable_params +from .load_tokenizer import load_model_and_tokenizer +from .loggings import get_logger + +if TYPE_CHECKING: + from transformers import PreTrainedTokenizer, Seq2SeqTrainingArguments, TrainerState + from transformers.trainer import PredictionOutput + + from ..configs.model_args import FinetuningArguments + + +logger = get_logger(__name__) + + +class PeftModelMixin: + r""" + Patches the save and load methods in Hugging Face Trainer for PeftModel and ModelWithValueHead. + """ + + def __init__(self) -> None: # for type checking + self.model: PreTrainedModel = None + self.tokenizer: "PreTrainedTokenizer" = None + self.args: "Seq2SeqTrainingArguments" = None + self.finetuning_args: "FinetuningArguments" = None + self.state: "TrainerState" = None + raise AssertionError("Mixin should not be initialized.") + + def _save( + self, + output_dir: Optional[str] = None, + state_dict: Optional[Dict[str, torch.Tensor]] = None, + ) -> None: + r""" + Saves trainable parameters as model checkpoint. + + This function will only be executed at the process zero. + + Subclass and override to inject custom behavior. It should not be directly used by external scripts. + """ + output_dir = output_dir if output_dir is not None else self.args.output_dir + os.makedirs(output_dir, exist_ok=True) + logger.info(f"Saving model checkpoint to {output_dir}") + + model = unwrap_model(self.model) + if isinstance(model, PreTrainedModelWrapper): + # Custom state dict: https://github.com/lvwerra/trl/blob/v0.4.7/trl/models/modeling_value_head.py#L200 + model_state_dict = state_dict or model.state_dict() + v_head_state_dict = { + name.replace("v_head.", ""): model_state_dict[name] + .cpu() + .clone() + .detach() + for name in model_state_dict.keys() + if name.startswith("v_head.") + } + + torch.save( + v_head_state_dict, os.path.join(output_dir, VALUE_HEAD_FILE_NAME) + ) + model = model.pretrained_model + + state_dict = state_dict or get_state_dict(model) + if isinstance(model, (PeftModel, PreTrainedModel)): + model.config.use_cache = True + model.save_pretrained( + output_dir, + state_dict=state_dict, + safe_serialization=self.args.save_safetensors, + ) + model.config.use_cache = False + else: + torch.save(state_dict, os.path.join(output_dir, WEIGHTS_NAME)) + + if ( + self.finetuning_args.finetuning_type == "full" + and self.tokenizer is not None + ): + try: + self.tokenizer.save_pretrained(output_dir) + except: + logger.warning("Cannot save tokenizer, copy the files manually.") + + with open( + os.path.join(output_dir, TRAINING_ARGS_NAME), "w", encoding="utf-8" + ) as f: + f.write(self.args.to_json_string() + "\n") + + self.finetuning_args.save_to_json( + os.path.join(output_dir, FINETUNING_ARGS_NAME) + ) + + def _load_best_model(self): + r""" + Loads trainable parameters from model checkpoint. + + Subclass and override to inject custom behavior. It should not be directly used by external scripts. + """ + logger.info( + f"Loading best model from {self.state.best_model_checkpoint} (score: {self.state.best_metric})." + ) + model = unwrap_model(self.model) + + if isinstance(model, PreTrainedModelWrapper): + model.v_head.load_state_dict( + torch.load( + os.path.join( + self.state.best_model_checkpoint, VALUE_HEAD_FILE_NAME + ), + map_location="cpu", + ) + ) + model = model.pretrained_model + + if isinstance(model, PeftModel): + model.load_adapter(self.state.best_model_checkpoint, model.active_adapter) + else: # freeze/full-tuning + load_trainable_params(model, self.state.best_model_checkpoint) + + +class PeftTrainer(PeftModelMixin, Seq2SeqTrainer): + r""" + Inherits Seq2SeqTrainer to support parameter-efficient checkpoints. + """ + + def __init__(self, finetuning_args: "FinetuningArguments", **kwargs): + Seq2SeqTrainer.__init__(self, **kwargs) + self.finetuning_args = finetuning_args + + +class Seq2SeqPeftTrainer(PeftTrainer): + r""" + Inherits PeftTrainer to compute generative metrics such as BLEU and ROUGE. + """ + + def prediction_step( + self, + model: nn.Module, + inputs: Dict[str, Union[torch.Tensor, Any]], + prediction_loss_only: bool, + ignore_keys: Optional[List[str]] = None, + ) -> Tuple[Optional[float], Optional[torch.Tensor], Optional[torch.Tensor]]: + r""" + Removes the prompt part in the generated tokens. + + Subclass and override to inject custom behavior. + """ + prompt_len, label_len = inputs["input_ids"].size(-1), inputs["labels"].size(-1) + if prompt_len > label_len: + inputs["labels"] = self._pad_tensors_to_target_len( + inputs["labels"], inputs["input_ids"] + ) + if label_len > prompt_len: + inputs["input_ids"] = self._pad_tensors_to_target_len( + inputs["input_ids"], inputs["labels"] + ) + if "attention_mask" in inputs: + inputs["attention_mask"] = self._pad_tensors_to_target_len( + inputs["attention_mask"], inputs["labels"], pad_token_id=0 + ) + if "position_ids" in inputs: + inputs["position_ids"] = self._pad_tensors_to_target_len( + inputs["position_ids"], inputs["labels"], pad_token_id=0 + ) + + loss, generated_tokens, labels = super().prediction_step( + model, + inputs, + prediction_loss_only=prediction_loss_only, + ignore_keys=ignore_keys, + ) + if generated_tokens is not None: + generated_tokens[ + :, : max(prompt_len, label_len) + ] = self.tokenizer.pad_token_id * torch.ones_like( + generated_tokens[:, : max(prompt_len, label_len)] + ) + + return loss, generated_tokens, labels + + def _pad_tensors_to_target_len( + self, + src_tensor: torch.Tensor, + tgt_tensor: torch.Tensor, + pad_token_id: Optional[int] = None, + ) -> torch.Tensor: + r""" + Pads the tensor to the same length as the target tensor. + + Should only be called when predict_with_generate=True. + """ + if pad_token_id is None: + if self.tokenizer is not None and hasattr(self.tokenizer, "pad_token_id"): + assert ( + self.tokenizer.padding_side == "left" + ), "This method only accepts left-padded tensor." + pad_token_id = self.tokenizer.pad_token_id + else: + raise ValueError("PAD token is required.") + + padded_tensor = pad_token_id * torch.ones_like(tgt_tensor) + padded_tensor[:, -src_tensor.shape[-1] :] = src_tensor # adopt left-padding + return padded_tensor.contiguous() # in contiguous memory + + def save_predictions(self, predict_results: "PredictionOutput") -> None: + r""" + Saves model predictions to `output_dir`. + + A custom behavior that not contained in Seq2SeqTrainer. + """ + if not self.is_world_process_zero(): + return + + output_prediction_file = os.path.join( + self.args.output_dir, "generated_predictions.jsonl" + ) + logger.info(f"Saving prediction results to {output_prediction_file}") + + preds = np.where( + predict_results.predictions != IGNORE_INDEX, + predict_results.predictions, + self.tokenizer.pad_token_id, + ) + labels = np.where( + predict_results.label_ids != IGNORE_INDEX, + predict_results.label_ids, + self.tokenizer.pad_token_id, + ) + + decoded_preds = self.tokenizer.batch_decode( + preds, skip_special_tokens=True, clean_up_tokenization_spaces=True + ) + decoded_labels = self.tokenizer.batch_decode( + labels, skip_special_tokens=True, clean_up_tokenization_spaces=True + ) + + with open(output_prediction_file, "w", encoding="utf-8") as writer: + res: List[str] = [] + for pred, label in zip(decoded_preds, decoded_labels): + res.append( + json.dumps({"label": label, "predict": pred}, ensure_ascii=False) + ) + writer.write("\n".join(res)) + + +@dataclass +class ComputeMetrics: + r""" + Wraps the tokenizer into metric functions, used in Seq2SeqPeftTrainer. + """ + + tokenizer: "PreTrainedTokenizer" + + def __call__( + self, eval_preds: Sequence[Union[np.ndarray, Tuple[np.ndarray]]] + ) -> Dict[str, float]: + r""" + Uses the model predictions to compute metrics. + """ + preds, labels = eval_preds + score_dict = {"rouge-1": [], "rouge-2": [], "rouge-l": [], "bleu-4": []} + + preds = np.where(preds != IGNORE_INDEX, preds, self.tokenizer.pad_token_id) + labels = np.where(labels != IGNORE_INDEX, labels, self.tokenizer.pad_token_id) + + decoded_preds = self.tokenizer.batch_decode(preds, skip_special_tokens=True) + decoded_labels = self.tokenizer.batch_decode(labels, skip_special_tokens=True) + + for pred, label in zip(decoded_preds, decoded_labels): + hypothesis = list(jieba.cut(pred)) + reference = list(jieba.cut(label)) + + if ( + len(" ".join(hypothesis).split()) == 0 + or len(" ".join(reference).split()) == 0 + ): + result = { + "rouge-1": {"f": 0.0}, + "rouge-2": {"f": 0.0}, + "rouge-l": {"f": 0.0}, + } + else: + rouge = Rouge() + scores = rouge.get_scores(" ".join(hypothesis), " ".join(reference)) + result = scores[0] + + for k, v in result.items(): + score_dict[k].append(round(v["f"] * 100, 4)) + + bleu_score = sentence_bleu( + [list(label)], + list(pred), + smoothing_function=SmoothingFunction().method3, + ) + score_dict["bleu-4"].append(round(bleu_score * 100, 4)) + + return {k: float(np.mean(v)) for k, v in score_dict.items()} + + +# Avoid runtime error in model.generate(do_sample=True). +class InvalidScoreLogitsProcessor(LogitsProcessor): + def __call__( + self, input_ids: torch.LongTensor, scores: torch.FloatTensor + ) -> torch.FloatTensor: + if torch.isnan(scores).any() or torch.isinf(scores).any(): + scores.zero_() + scores[..., 0] = 1.0 + return scores + + +def get_logits_processor() -> LogitsProcessorList: + logits_processor = LogitsProcessorList() + logits_processor.append(InvalidScoreLogitsProcessor()) + return logits_processor + + +# metric used +def smooth(scalars: List[float]) -> List[float]: + r""" + EMA implementation according to TensorBoard. + """ + last = scalars[0] + smoothed = list() + weight = 1.8 * ( + 1 / (1 + math.exp(-0.05 * len(scalars))) - 0.5 + ) # a sigmoid function + for next_val in scalars: + smoothed_val = last * weight + (1 - weight) * next_val + smoothed.append(smoothed_val) + last = smoothed_val + return smoothed + + +def plot_loss( + save_dictionary: os.PathLike, keys: Optional[List[str]] = ["loss"] +) -> None: + with open( + os.path.join(save_dictionary, TRAINER_STATE_NAME), "r", encoding="utf-8" + ) as f: + data = json.load(f) + + for key in keys: + steps, metrics = [], [] + for i in range(len(data["log_history"])): + if key in data["log_history"][i]: + steps.append(data["log_history"][i]["step"]) + metrics.append(data["log_history"][i][key]) + + if len(metrics) == 0: + logger.warning(f"No metric {key} to plot.") + continue + + plt.figure() + plt.plot(steps, metrics, alpha=0.4, label="original") + plt.plot(steps, smooth(metrics), label="smoothed") + plt.title("training {} of {}".format(key, save_dictionary)) + plt.xlabel("step") + plt.ylabel(key) + plt.legend() + plt.savefig( + os.path.join(save_dictionary, "training_{}.png".format(key)), + format="png", + dpi=100, + ) + print( + "Figure saved:", + os.path.join(save_dictionary, "training_{}.png".format(key)), + ) + + +def export_model( + args: Optional[Dict[str, Any]] = None, max_shard_size: Optional[str] = "10GB" +): + model_args, _, training_args, finetuning_args, _ = get_train_args( + args, data_args_init=False + ) + model, tokenizer = load_model_and_tokenizer(model_args, finetuning_args) + model.save_pretrained(training_args.output_dir, max_shard_size=max_shard_size) + try: + tokenizer.save_pretrained(training_args.output_dir) + except: + logger.warning("Cannot save tokenizer, please copy the files manually.") diff --git a/src/backend/tests/text2sql/predict/__init__.py b/src/backend/tests/text2sql/predict/__init__.py new file mode 100644 index 0000000..d9cb30e --- /dev/null +++ b/src/backend/tests/text2sql/predict/__init__.py @@ -0,0 +1,8 @@ +""" +dbgpt_hub.predict +============== +""" + +from .predict_api import start_predict + +__all__ = ["start_predict"] diff --git a/src/backend/tests/text2sql/predict/predict.py b/src/backend/tests/text2sql/predict/predict.py new file mode 100644 index 0000000..3db979e --- /dev/null +++ b/src/backend/tests/text2sql/predict/predict.py @@ -0,0 +1,51 @@ +import json +import os +import sys + +ROOT_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(ROOT_PATH) + +from typing import Any, Dict, List, Optional + +from dbgpt_hub_sql.data_process.data_utils import extract_sql_prompt_dataset +from dbgpt_hub_sql.llm_base.chat_model import ChatModel +from tqdm import tqdm + + +def prepare_dataset( + predict_file_path: Optional[str] = None, +) -> List[Dict]: + with open(predict_file_path, "r") as fp: + data = json.load(fp) + predict_data = [extract_sql_prompt_dataset(item) for item in data] + return predict_data + + +def inference(model: ChatModel, predict_data: List[Dict], **input_kwargs): + res = [] + # test + # for item in predict_data[:20]: + for item in tqdm(predict_data, desc="Inference Progress", unit="item"): + print(f"item[input] \n{item['input']}") + response, _ = model.chat(query=item["input"], history=[], **input_kwargs) + res.append(response) + return res + + +def predict(model: ChatModel): + args = model.data_args + ## predict file can be give by param --predicted_input_filename ,output_file can be gived by param predicted_out_filename + predict_data = prepare_dataset(args.predicted_input_filename) + result = inference(model, predict_data) + + with open(args.predicted_out_filename, "w") as f: + for p in result: + try: + f.write(p.replace("\n", " ") + "\n") + except: + f.write("Invalid Output!\n") + + +if __name__ == "__main__": + model = ChatModel() + predict(model) diff --git a/src/backend/tests/text2sql/predict/predict_api.py b/src/backend/tests/text2sql/predict/predict_api.py new file mode 100644 index 0000000..f9a105c --- /dev/null +++ b/src/backend/tests/text2sql/predict/predict_api.py @@ -0,0 +1,32 @@ +import os +from typing import Any, Dict, Optional + +from dbgpt_hub_sql.predict import predict + + +def start_predict( + args: Optional[Dict[str, Any]] = None, cuda_visible_devices: Optional[str] = "0" +): + # Setting CUDA Device + os.environ["CUDA_VISIBLE_DEVICES"] = cuda_visible_devices + + # Default Arguments + if args is None: + args = { + "model_name_or_path": "codellama/CodeLlama-13b-Instruct-hf", + "template": "llama2", + "finetuning_type": "lora", + "checkpoint_dir": "dbgpt_hub_sql/output/adapter/CodeLlama-13b-sql-lora", + "predict_file_path": "dbgpt_hub_sql/data/eval_data/dev_sql.json", + "predict_out_dir": "dbgpt_hub_sql/output/", + "predicted_out_filename": "pred_sql.sql", + } + else: + args = args + + # Execute prediction + predict.predict(args) + + +if __name__ == "__main__": + start_predict() diff --git a/src/backend/tests/text2sql/scripts/export_merge.sh b/src/backend/tests/text2sql/scripts/export_merge.sh new file mode 100644 index 0000000..37c1d0a --- /dev/null +++ b/src/backend/tests/text2sql/scripts/export_merge.sh @@ -0,0 +1,19 @@ +# llama2 series +python dbgpt_hub_sql/train/export_model.py \ + --model_name_or_path /home/LLM/CodeLlama-13b-Instruct-hf \ + --template llama2 \ + --finetuning_type lora \ + --checkpoint_dir dbgpt_hub_sql/output/adapter/CodeLlama-13b-sql-lora \ + --output_dir dbgpt_hub_sql/output/codellama-13b-sql-sft \ + --fp16 + + +## Baichuan2 +# python dbgpt_hub_sql/train/export_model.py \ +# --model_name_or_path Your_base_model_path_like_Baichuan2-13B-Chat \ +# --template Your_template_like_baichuan2_eval \ +# --finetuning_type lora \ +# --checkpoint_dir Your_ckpt_path_checkpoint-100 \ +# --output_dir Your_export_model_like_output_merge_model_baichuan2-13b-qlora_merge \ +# --fp16 +# # --bf16 \ No newline at end of file diff --git a/src/backend/tests/text2sql/scripts/gen_train_eval_data.sh b/src/backend/tests/text2sql/scripts/gen_train_eval_data.sh new file mode 100644 index 0000000..a5600bd --- /dev/null +++ b/src/backend/tests/text2sql/scripts/gen_train_eval_data.sh @@ -0,0 +1,2 @@ +# base spider dataset ,produce train and dev data +python dbgpt_hub_sql/data_process/sql_data_process.py \ No newline at end of file diff --git a/src/backend/tests/text2sql/scripts/predict_sft.sh b/src/backend/tests/text2sql/scripts/predict_sft.sh new file mode 100644 index 0000000..6f327a4 --- /dev/null +++ b/src/backend/tests/text2sql/scripts/predict_sft.sh @@ -0,0 +1,39 @@ +## shijian llama2 test + +current_date=$(date +"%Y%m%d_%H%M") +pred_log="dbgpt_hub_sql/output/logs/pred_test_${current_date}.log" +start_time=$(date +%s) +echo " Pred Start time: $(date -d @$start_time +'%Y-%m-%d %H:%M:%S')" >>${pred_log} + +CUDA_VISIBLE_DEVICES=0,1 python dbgpt_hub_sql/predict/predict.py \ + --model_name_or_path Your_download_CodeLlama-13b-Instruct-hf_path \ + --template llama2 \ + --finetuning_type lora \ + --predicted_input_filename dbgpt_hub_sql/data/example_text2sql_dev.json \ + --checkpoint_dir dbgpt_hub_sql/output/adapter/CodeLlama-13b-sql-lora \ + --predicted_out_filename dbgpt_hub_sql/output/pred/pred_codellama13b.sql >> ${pred_log} + +echo "############pred end###############" >>${pred_log} +echo "pred End time: $(date)" >>${pred_log} +end_time=$(date +%s) +duration=$((end_time - start_time)) +hours=$((duration / 3600)) +min=$(( (duration % 3600) / 60)) +echo "Time elapsed: ${hour} hour $min min " >>${pred_log} + + +# # wangzai baichua2_eval test +# CUDA_VISIBLE_DEVICES=0 python dbgpt_hub_sql/predict/predict.py \ +# --model_name_or_path /home/model/Baichuan2-13B-Chat \ +# --template baichuan2_eval \ +# --quantization_bit 4 \ +# --finetuning_type lora \ +# --checkpoint_dir dbgpt_hub_sql/output/adapter/baichuan2-13b-qlora + + +## wangzai codellama2_pred test a100 +# CUDA_VISIBLE_DEVICES=0,1 python dbgpt_hub_sql/predict/predict.py \ +# --model_name_or_path /home/model_files/codellama/CodeLlama-7b-Instruct-hf \ +# --template llama2 \ +# --finetuning_type lora \ +# --checkpoint_dir dbgpt_hub_sql/output/adapter/code_llama_7b-qlora \ No newline at end of file diff --git a/src/backend/tests/text2sql/scripts/train_rm.sh b/src/backend/tests/text2sql/scripts/train_rm.sh new file mode 100644 index 0000000..39d2847 --- /dev/null +++ b/src/backend/tests/text2sql/scripts/train_rm.sh @@ -0,0 +1,43 @@ +wandb offline # Close wandb +# a100 ,单卡 +current_date=$(date +"%Y%m%d_%H%M") +train_log="dbgpt_hub_sql/output/logs/train_sft_test_${current_date}.log" +start_time=$(date +%s) +echo " Train Start time: $(date -d @$start_time +'%Y-%m-%d %H:%M:%S')" >>${train_log} + +# the default param set could be run in a server with one a100(40G) gpu, if your server not support the set,you can set smaller param such as lora_rank and use qlora with quant 4 eg... +deepspeed --num_gpus 4 dbgpt_hub_sql/train/rm_train.py \ + --deepspeed dbgpt_hub_sql/configs/ds_config.json \ + --stage rm \ + --model_name_or_path /home/CPF/LLM/qwen-7b-chat \ + --do_train \ + --dataset example_rm_train \ + --max_source_length 1024 \ + --max_target_length 512 \ + --finetuning_type lora \ + --lora_target c_attn \ + --template chatml \ + --lora_rank 64 \ + --lora_alpha 32 \ + --output_dir dbgpt_hub_sql/output/adapter/qwen-7b-rm-test \ + --overwrite_cache \ + --overwrite_output_dir \ + --per_device_train_batch_size 1 \ + --gradient_accumulation_steps 2 \ + --lr_scheduler_type cosine_with_restarts \ + --logging_steps 50 \ + --save_steps 2000 \ + --learning_rate 1e-6 \ + --num_train_epochs 0.05 \ + --plot_loss True \ + --quantization_bit 4 >> ${train_log} + # --bf16 + # --bf16#v100不支持bf16 + +echo "############train end###############" >>${train_log} +echo "Train End time: $(date)" >>${train_log} +end_time=$(date +%s) +duration=$((end_time - start_time)) +hours=$((duration / 3600)) +min=$(( (duration % 3600) / 60)) +echo "Time elapsed: ${hour} hour $min min " >>${train_log} \ No newline at end of file diff --git a/src/backend/tests/text2sql/scripts/train_sft.sh b/src/backend/tests/text2sql/scripts/train_sft.sh new file mode 100644 index 0000000..05ae38a --- /dev/null +++ b/src/backend/tests/text2sql/scripts/train_sft.sh @@ -0,0 +1,104 @@ +wandb offline # Close wandb +# a100 ,单卡 +current_date=$(date +"%Y%m%d_%H%M") +train_log="dbgpt_hub_sql/output/logs/train_sft_test_${current_date}.log" +start_time=$(date +%s) +echo " Train Start time: $(date -d @$start_time +'%Y-%m-%d %H:%M:%S')" >>${train_log} + +# default train , zero-shot, +num_shot=0 + +# one-shot train +# num_shot=1 + +dataset="example_text2sql_train" +if [ "$num_shot" -eq 1 ]; then + dataset="example_text2sql_train_one_shot" +fi +model_name_or_path=${model_name_or_path-"codellama/CodeLlama-13b-Instruct-hf"} +output_dir="dbgpt_hub_sql/output/adapter/CodeLlama-13b-sql-lora" + +# the default param set could be run in a server with one a100(40G) gpu, if your server not support the set,you can set smaller param such as lora_rank and use qlora with quant 4 eg... +CUDA_VISIBLE_DEVICES=0 python dbgpt_hub_sql/train/sft_train.py \ + --model_name_or_path $model_name_or_path \ + --do_train \ + --dataset $dataset \ + --max_source_length 2048 \ + --max_target_length 512 \ + --finetuning_type lora \ + --lora_target q_proj,v_proj \ + --template llama2 \ + --lora_rank 64 \ + --lora_alpha 32 \ + --output_dir $output_dir \ + --overwrite_cache \ + --overwrite_output_dir \ + --per_device_train_batch_size 1 \ + --gradient_accumulation_steps 16 \ + --lr_scheduler_type cosine_with_restarts \ + --logging_steps 50 \ + --save_steps 2000 \ + --learning_rate 2e-4 \ + --num_train_epochs 8 \ + --plot_loss \ + --bf16 >> ${train_log} + # --bf16#v100不支持bf16 + +echo "############train end###############" >>${train_log} +echo "Train End time: $(date)" >>${train_log} +end_time=$(date +%s) +duration=$((end_time - start_time)) +hours=$((duration / 3600)) +min=$(( (duration % 3600) / 60)) +echo "Time elapsed: ${hour} hour $min min " >>${train_log} + + +# 多卡,deepseed启动,A100 +# deepspeed --num_gpus 2 dbgpt_hub_sql/train/sft_train.py \ +# --deepspeed dbgpt_hub_sql/configs/stage2.json \ +# --quantization_bit 4 \ +# --model_name_or_path /home/model_files/Llama-2-13b-chat-hf \ +# --do_train \ +# --dataset example_text2sql_train \ +# --max_source_length 1024 \ +# --max_target_length 512 \ +# --template llama2 \ +# --finetuning_type lora \ +# --lora_rank 64 \ +# --lora_alpha 32 \ +# --lora_target q_proj,v_proj \ +# --output_dir dbgpt_hub_sql/output/adapter/llama2-13b-qlora_1024_epoch1_debug1008_withDeepseed_mulitCard \ +# --overwrite_cache \ +# --overwrite_output_dir \ +# --per_device_train_batch_size 1 \ +# --gradient_accumulation_steps 16 \ +# --lr_scheduler_type cosine_with_restarts \ +# --logging_steps 25 \ +# --save_steps 20 \ +# --learning_rate 2e-4 \ +# --num_train_epochs 0.1 \ +# --plot_loss \ +# --bf16 2>&1 | tee ${train_log} + + +# 多卡,deepseed,全量微调 +# deepspeed --include localhost:4,5,6,7 dbgpt_hub_sql/train/sft_train.py \ +# --dataset example_text2sql_train \ +# --model_name_or_path CodeLlama-7b-Instruct-hf \ +# --do_train \ +# --finetuning_type full \ +# --max_source_length 2048 \ +# --max_target_length 512 \ +# --template llama2 \ +# --output_dir dbgpt_hub_sql/output/adapter/code-llama-7b-2048_epoch4_full \ +# --overwrite_cache \ +# --overwrite_output_dir \ +# --per_device_train_batch_size 4 \ +# --gradient_accumulation_steps 16 \ +# --lr_scheduler_type cosine_with_restarts \ +# --logging_steps 50 \ +# --learning_rate 2e-5 \ +# --num_train_epochs 4 \ +# --plot_loss \ +# --bf16 True\ +# --deepspeed dbgpt_hub_sql/configs/stage3.json 2>&1 | tee ${train_log} diff --git a/src/backend/tests/text2sql/train/__init__.py b/src/backend/tests/text2sql/train/__init__.py new file mode 100644 index 0000000..4b57815 --- /dev/null +++ b/src/backend/tests/text2sql/train/__init__.py @@ -0,0 +1,8 @@ +""" +dbgpt_hub.train +============== +""" + +from .sft_train_api import start_sft + +__all__ = ["start_sft"] diff --git a/src/backend/tests/text2sql/train/export_model.py b/src/backend/tests/text2sql/train/export_model.py new file mode 100644 index 0000000..28b75c3 --- /dev/null +++ b/src/backend/tests/text2sql/train/export_model.py @@ -0,0 +1,14 @@ +import os +import sys + +ROOT_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(ROOT_PATH) +from dbgpt_hub_sql.llm_base.model_trainer import export_model + + +def main(): + export_model() + + +if __name__ == "__main__": + main() diff --git a/src/backend/tests/text2sql/train/rm_train.py b/src/backend/tests/text2sql/train/rm_train.py new file mode 100644 index 0000000..f3aef8c --- /dev/null +++ b/src/backend/tests/text2sql/train/rm_train.py @@ -0,0 +1,318 @@ +import json +import os +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union + +import numpy as np +import torch +from dbgpt_hub_sql.data_process.data_utils import ( + get_dataset, + preprocess_dataset, + split_dataset, +) +from dbgpt_hub_sql.llm_base.config_parser import get_train_args +from dbgpt_hub_sql.llm_base.load_tokenizer import load_model_and_tokenizer +from dbgpt_hub_sql.llm_base.loggings import LogCallback, get_logger +from dbgpt_hub_sql.llm_base.model_trainer import plot_loss +from transformers import ( + DataCollatorWithPadding, + Seq2SeqTrainingArguments, + Trainer, + TrainerCallback, + TrainerControl, + TrainerState, + TrainingArguments, +) +from transformers.modeling_utils import custom_object_save, unwrap_model +from transformers.trainer_utils import PREFIX_CHECKPOINT_DIR, has_length + +if TYPE_CHECKING: + from dbgpt_hub_sql.configs.data_args import DataArguments + from dbgpt_hub_sql.configs.model_args import ( + FinetuningArguments, + GeneratingArguments, + ModelArguments, + ) + from transformers.modeling_utils import PreTrainedModel + from transformers.trainer import PredictionOutput + from trl import AutoModelForCausalLMWithValueHead + +logger = get_logger(__name__) + + +@dataclass +class PairwiseDataCollatorWithPadding(DataCollatorWithPadding): + r""" + Data collator for pairwise data. + """ + + def __call__(self, features: Sequence[Dict[str, Any]]) -> Dict[str, torch.Tensor]: + r""" + Pads batched data to the longest sequence in the batch. + + We generate 2 * n examples where the first n examples represent chosen examples and + the last n examples represent rejected examples. + """ + features = [ + { + "input_ids": feature["prompt_ids"] + feature[key], + "attention_mask": [1] + * (len(feature["prompt_ids"]) + len(feature[key])), + } + for key in ("chosen_ids", "rejected_ids") + for feature in features + ] + return super().__call__(features) + + +class PairwiseTrainer(Trainer): + r""" + Inherits PeftTrainer to compute pairwise loss. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.can_return_loss = True # override property to return eval_loss + + def compute_loss( + self, + model: "PreTrainedModel", + inputs: Dict[str, torch.Tensor], + return_outputs: Optional[bool] = False, + ) -> Union[torch.Tensor, Tuple[torch.Tensor, List[torch.Tensor]]]: + r""" + Computes pairwise loss. The first n examples are chosen and the last n examples are rejected. + + Subclass and override to inject custom behavior. + + Note that the first element will be removed from the output tuple. + See: https://github.com/huggingface/transformers/blob/v4.30.2/src/transformers/trainer.py#L3509 + """ + # Compute rewards + _, _, values = model(**inputs, output_hidden_states=True, return_dict=True) + + unwrapped_model: "PreTrainedModel" = self.accelerator.unwrap_model(self.model) + if getattr(unwrapped_model.config, "model_type", None) == "chatglm": + values = torch.transpose(values, 0, 1) + + # Split the inputs and rewards into two parts, chosen and rejected + batch_size = inputs["input_ids"].size(0) // 2 + chosen_input_ids, rejected_input_ids = ( + inputs["input_ids"][:batch_size], + inputs["input_ids"][batch_size:], + ) + chosen_rewards, rejected_rewards = values[:batch_size], values[batch_size:] + chosen_scores, rejected_scores = [], [] + + # Compute pairwise loss. Only backprop on the different tokens before padding + # Inspired by: https://github.com/CarperAI/trlx/blob/main/examples/summarize_rlhf/reward_model/reward_model.py + loss = 0 + for i in range(batch_size): + chosen_length = ( + chosen_input_ids[i] != self.tokenizer.pad_token_id + ).nonzero()[-1] + 1 + rejected_length = ( + rejected_input_ids[i] != self.tokenizer.pad_token_id + ).nonzero()[-1] + 1 + check_divergence = (chosen_input_ids[i] != rejected_input_ids[i]).nonzero() + + if len(check_divergence) == 0: + end_index = chosen_length + div_index = end_index - 1 + else: + end_index = max(chosen_length, rejected_length) + div_index = check_divergence[0] + + assert div_index > 0 + chosen_trunc_rewards = chosen_rewards[i, div_index:end_index] + rejected_trunc_rewards = rejected_rewards[i, div_index:end_index] + if ( + return_outputs + ): # use the score on the last token except pad token for inference + chosen_scores.append(chosen_rewards[i, chosen_length - 1]) + rejected_scores.append(rejected_rewards[i, rejected_length - 1]) + loss += -torch.nn.functional.logsigmoid( + chosen_trunc_rewards - rejected_trunc_rewards + ).mean() + + loss = loss / batch_size + if return_outputs: + chosen_scores, rejected_scores = torch.stack(chosen_scores), torch.stack( + rejected_scores + ) + return loss, [loss, chosen_scores, rejected_scores] + + return loss + + def save_predictions(self, predict_results: "PredictionOutput") -> None: + r""" + Saves model predictions to `output_dir`. + + A custom behavior that not contained in Seq2SeqTrainer. + """ + if not self.is_world_process_zero(): + return + + output_prediction_file = os.path.join( + self.args.output_dir, "generated_predictions.jsonl" + ) + logger.info(f"Saving prediction results to {output_prediction_file}") + chosen_scores, rejected_scores = predict_results.predictions + + with open(output_prediction_file, "w", encoding="utf-8") as writer: + res: List[str] = [] + for c_score, r_score in zip(chosen_scores, rejected_scores): + res.append( + json.dumps( + { + "chosen": round(float(c_score), 2), + "rejected": round(float(r_score), 2), + } + ) + ) + writer.write("\n".join(res)) + + +class SavePeftModelCallback(TrainerCallback): + def _save_model_with_valuehead( + self, model: "AutoModelForCausalLMWithValueHead", output_dir: str + ) -> None: + model.pretrained_model.config.save_pretrained(output_dir) + if model.pretrained_model.can_generate(): + model.pretrained_model.generation_config.save_pretrained(output_dir) + if getattr(model, "is_peft_model", False): + model.pretrained_model.save_pretrained(output_dir) + elif getattr( + model.pretrained_model, "_auto_class", None + ): # must not a peft model + custom_object_save( + model.pretrained_model, output_dir, config=model.pretrained_model.config + ) + + def on_save( + self, + args: "TrainingArguments", + state: "TrainerState", + control: "TrainerControl", + **kwargs, + ): + r""" + Event called after a checkpoint save. + """ + if args.should_save: + self._save_model_with_valuehead( + model=unwrap_model(kwargs.pop("model")), + output_dir=os.path.join( + args.output_dir, + "{}-{}".format(PREFIX_CHECKPOINT_DIR, state.global_step), + ), + ) + + def on_train_end( + self, + args: "TrainingArguments", + state: "TrainerState", + control: "TrainerControl", + **kwargs, + ): + r""" + Event called at the end of training. + """ + + if args.should_save: + self._save_model_with_valuehead( + model=unwrap_model(kwargs.pop("model")), output_dir=args.output_dir + ) + + +def compute_accuracy( + eval_preds: Sequence[Union[np.ndarray, Tuple[np.ndarray]]] +) -> Dict[str, float]: + preds, _ = eval_preds + return {"accuracy": (preds[0] > preds[1]).sum() / len(preds[0])} + + +def run_rm( + model_args: "ModelArguments", + data_args: "DataArguments", + training_args: "Seq2SeqTrainingArguments", + finetuning_args: "FinetuningArguments", + callbacks: Optional[List["TrainerCallback"]] = None, +): + dataset = get_dataset(model_args, data_args) + model, tokenizer = load_model_and_tokenizer( + model_args, finetuning_args, training_args.do_train, add_valuehead=True + ) + dataset = preprocess_dataset( + dataset, tokenizer, data_args, training_args, stage="rm" + ) + data_collator = PairwiseDataCollatorWithPadding(tokenizer, pad_to_multiple_of=8) + + # Update arguments + training_args_dict = training_args.to_dict() + training_args_dict.update( + dict(remove_unused_columns=False) + ) # important for pairwise dataset + training_args = Seq2SeqTrainingArguments(**training_args_dict) + + # Initialize our Trainer + trainer = PairwiseTrainer( + model=model, + args=training_args, + tokenizer=tokenizer, + data_collator=data_collator, + callbacks=callbacks + [SavePeftModelCallback()], + compute_metrics=compute_accuracy, + **split_dataset(dataset, data_args, training_args), + ) + + # Training + if training_args.do_train: + train_result = trainer.train( + resume_from_checkpoint=training_args.resume_from_checkpoint + ) + trainer.save_model() + trainer.log_metrics("train", train_result.metrics) + trainer.save_metrics("train", train_result.metrics) + trainer.save_state() + if trainer.is_world_process_zero() and model_args.plot_loss: + plot_loss(training_args.output_dir, keys=["loss", "eval_loss"]) + + # Evaluation + if training_args.do_eval: + metrics = trainer.evaluate(metric_key_prefix="eval") + trainer.log_metrics("eval", metrics) + trainer.save_metrics("eval", metrics) + + # Predict + if training_args.do_predict: + predict_results = trainer.predict(dataset, metric_key_prefix="predict") + trainer.log_metrics("predict", predict_results.metrics) + trainer.save_metrics("predict", predict_results.metrics) + trainer.save_predictions(predict_results) + + +def train( + args: Optional[Dict[str, Any]] = None, + callbacks: Optional[List["TrainerCallback"]] = None, +): + ( + model_args, + data_args, + training_args, + finetuning_args, + generating_args, + ) = get_train_args(args) + callbacks = [LogCallback()] if callbacks is None else callbacks + + run_rm( + model_args, + data_args, + training_args, + finetuning_args, + callbacks, + ) + + +if __name__ == "__main__": + train() diff --git a/src/backend/tests/text2sql/train/sft_train.py b/src/backend/tests/text2sql/train/sft_train.py new file mode 100644 index 0000000..1f5123b --- /dev/null +++ b/src/backend/tests/text2sql/train/sft_train.py @@ -0,0 +1,164 @@ +import os +import sys + +ROOT_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(ROOT_PATH) +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from dbgpt_hub_sql.configs.config import IGNORE_INDEX +from dbgpt_hub_sql.data_process.data_utils import ( + get_dataset, + preprocess_dataset, + split_dataset, +) +from dbgpt_hub_sql.llm_base.config_parser import get_train_args +from dbgpt_hub_sql.llm_base.load_tokenizer import load_model_and_tokenizer +from dbgpt_hub_sql.llm_base.loggings import LogCallback, get_logger +from dbgpt_hub_sql.llm_base.model_trainer import ( + ComputeMetrics, + Seq2SeqPeftTrainer, + get_logits_processor, + plot_loss, +) +from transformers import DataCollatorForSeq2Seq, Seq2SeqTrainingArguments + +if TYPE_CHECKING: + from dbgpt_hub_sql.configs import ( + DataArguments, + FinetuningArguments, + GeneratingArguments, + ModelArguments, + ) + from transformers import TrainerCallback + + +logger = get_logger(__name__) + + +def run_sft( + model_args: "ModelArguments", + data_args: "DataArguments", + training_args: "Seq2SeqTrainingArguments", + finetuning_args: "FinetuningArguments", + generating_args: "GeneratingArguments", + callbacks: Optional[List["TrainerCallback"]] = None, +): + dataset = get_dataset(model_args, data_args) + model, tokenizer = load_model_and_tokenizer( + model_args, finetuning_args, training_args.do_train + ) + dataset = preprocess_dataset(dataset, tokenizer, data_args, training_args, "sft") + data_collator = DataCollatorForSeq2Seq( + tokenizer=tokenizer, + label_pad_token_id=IGNORE_INDEX + if data_args.ignore_pad_token_for_loss + else tokenizer.pad_token_id, + ) + + # Override the decoding parameters of Seq2SeqTrainer + training_args_dict = training_args.to_dict() + training_args_dict.update( + dict( + generation_max_length=training_args.generation_max_length + or data_args.max_target_length, + generation_num_beams=data_args.eval_num_beams + or training_args.generation_num_beams, + ) + ) + training_args = Seq2SeqTrainingArguments(**training_args_dict) + + # Initialize our Trainer + trainer = Seq2SeqPeftTrainer( + finetuning_args=finetuning_args, + model=model, + args=training_args, + tokenizer=tokenizer, + data_collator=data_collator, + callbacks=callbacks, + compute_metrics=ComputeMetrics(tokenizer) + if training_args.predict_with_generate + else None, + **split_dataset(dataset, data_args, training_args) + ) + + # Keyword arguments for `model.generate` + gen_kwargs = generating_args.to_dict() + gen_kwargs["eos_token_id"] = list( + set([tokenizer.eos_token_id] + tokenizer.additional_special_tokens_ids) + ) + gen_kwargs["pad_token_id"] = tokenizer.pad_token_id + gen_kwargs["logits_processor"] = get_logits_processor() + + # Training + if training_args.do_train: + train_result = trainer.train( + resume_from_checkpoint=training_args.resume_from_checkpoint + ) + trainer.log_metrics("train", train_result.metrics) + trainer.save_metrics("train", train_result.metrics) + trainer.save_state() + trainer.save_model() + if trainer.is_world_process_zero() and model_args.plot_loss: + plot_loss(training_args.output_dir, keys=["loss", "eval_loss"]) + + # Evaluation + if training_args.do_eval: + metrics = trainer.evaluate(metric_key_prefix="eval", **gen_kwargs) + if ( + training_args.predict_with_generate + ): # eval_loss will be wrong if predict_with_generate is enabled + metrics.pop("eval_loss", None) + trainer.log_metrics("eval", metrics) + trainer.save_metrics("eval", metrics) + + # Predict + if training_args.do_predict: + predict_results = trainer.predict( + dataset, metric_key_prefix="predict", **gen_kwargs + ) + if ( + training_args.predict_with_generate + ): # predict_loss will be wrong if predict_with_generate is enabled + predict_results.metrics.pop("predict_loss", None) + trainer.log_metrics("predict", predict_results.metrics) + trainer.save_metrics("predict", predict_results.metrics) + trainer.save_predictions(predict_results) + + +def train( + args: Optional[Dict[str, Any]] = None, + callbacks: Optional[List["TrainerCallback"]] = None, +): + ( + model_args, + data_args, + training_args, + finetuning_args, + generating_args, + ) = get_train_args(args) + callbacks = [LogCallback()] if callbacks is None else callbacks + + run_sft( + model_args, + data_args, + training_args, + finetuning_args, + generating_args, + callbacks, + ) + + +def export_model( + args: Optional[Dict[str, Any]] = None, max_shard_size: Optional[str] = "10GB" +): + model_args, _, training_args, finetuning_args, _ = get_train_args(args) + model, tokenizer = load_model_and_tokenizer(model_args, finetuning_args) + model.save_pretrained(training_args.output_dir, max_shard_size=max_shard_size) + try: + tokenizer.save_pretrained(training_args.output_dir) + except: + logger.warning("Cannot save tokenizer, please copy the files manually.") + + +if __name__ == "__main__": + train() diff --git a/src/backend/tests/text2sql/train/sft_train_api.py b/src/backend/tests/text2sql/train/sft_train_api.py new file mode 100644 index 0000000..c7c8d49 --- /dev/null +++ b/src/backend/tests/text2sql/train/sft_train_api.py @@ -0,0 +1,47 @@ +import os +from typing import Any, Dict, Optional + +from dbgpt_hub_sql.train import sft_train + + +def start_sft( + args: Optional[Dict[str, Any]] = None, cuda_visible_devices: Optional[str] = "0" +): + # Setting CUDA Device + os.environ["CUDA_VISIBLE_DEVICES"] = cuda_visible_devices + + # Default Arguments + if args is None: + args = { + "model_name_or_path": "codellama/CodeLlama-13b-Instruct-hf", + "do_train": True, + "dataset": "example_text2sql_train", + "max_source_length": 2048, + "max_target_length": 512, + "finetuning_type": "lora", + "lora_target": "q_proj,v_proj", + "template": "llama2", + "lora_rank": 64, + "lora_alpha": 32, + "output_dir": "dbgpt_hub_sql/output/adapter/CodeLlama-13b-sql-lora", + "overwrite_cache": True, + "overwrite_output_dir": True, + "per_device_train_batch_size": 1, + "gradient_accumulation_steps": 16, + "lr_scheduler_type": "cosine_with_restarts", + "logging_steps": 50, + "save_steps": 2000, + "learning_rate": 2e-4, + "num_train_epochs": 8, + "plot_loss": True, + "bf16": True, + } + else: + args = args + + # Run SFT + sft_train.train(args) + + +if __name__ == "__main__": + start_sft() diff --git a/src/backend/tests/util/__init__.py b/src/backend/tests/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/tests/util/test_log.py b/src/backend/tests/util/test_log.py new file mode 100644 index 0000000..75ded8d --- /dev/null +++ b/src/backend/tests/util/test_log.py @@ -0,0 +1,77 @@ +import os +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock + +from app.utils.log import LogHelper + + +class TestLogHelper: + """Test cases for LogHelper class""" + + def test_log_helper_initialization(self, tmp_path): + """Test LogHelper initialization with default parameters""" + # Mock Profile.get_project_root to return a temporary directory + with patch('app.utils.profile.Profile.get_project_root') as mock_get_project_root: + mock_get_project_root.return_value = tmp_path + + # Mock config to avoid dependency issues + mock_config = MagicMock() + mock_config.log.console_level = "INFO" + mock_config.log.file_level = "DEBUG" + mock_config.log.rotation = "500 MB" + mock_config.log.retention = "10 days" + + with patch('app.server.config', mock_config): + log_helper = LogHelper() + + # Check that log directory was created + log_dir = tmp_path / "logs" + assert log_dir.exists() + assert log_dir.is_dir() + + # Check that logger was initialized + assert log_helper.logger is not None + + def test_log_helper_with_custom_log_name(self, tmp_path): + """Test LogHelper initialization with custom log file name""" + with patch('app.utils.profile.Profile.get_project_root') as mock_get_project_root: + mock_get_project_root.return_value = tmp_path + + # Mock config + mock_config = MagicMock() + mock_config.log.console_level = "INFO" + mock_config.log.file_level = "DEBUG" + mock_config.log.rotation = "500 MB" + mock_config.log.retention = "10 days" + + with patch('app.server.config', mock_config): + log_helper = LogHelper("custom_log") + + # Check that log file with custom name would be created + log_file = tmp_path / "logs" / "custom_log.log" + # Note: Loguru creates the file when first writing to it, not during initialization + + # Check that logger was initialized + assert log_helper.logger is not None + + def test_get_logger(self, tmp_path): + """Test get_logger method returns the same logger instance""" + with patch('app.utils.profile.Profile.get_project_root') as mock_get_project_root: + mock_get_project_root.return_value = tmp_path + + # Mock config + mock_config = MagicMock() + mock_config.log.console_level = "INFO" + mock_config.log.file_level = "DEBUG" + mock_config.log.rotation = "500 MB" + mock_config.log.retention = "10 days" + + with patch('app.server.config', mock_config): + log_helper = LogHelper() + logger1 = log_helper.get_logger() + logger2 = log_helper.get_logger() + + # Check that the same logger instance is returned (due to lru_cache) + assert logger1 is logger2 + assert logger1 == logger2 \ No newline at end of file diff --git a/src/backend/tests/util/test_profile.py b/src/backend/tests/util/test_profile.py new file mode 100644 index 0000000..be488f3 --- /dev/null +++ b/src/backend/tests/util/test_profile.py @@ -0,0 +1,43 @@ +from pathlib import Path +import tempfile +import os + +from app.utils.profile import Profile + +class TestProfile: + """Test cases for Profile utility class""" + + def test_get_project_root(self): + """Test that get_project_root returns a Path object pointing to the project root""" + project_root = Profile.get_project_root() + assert isinstance(project_root, Path) + # Check that we can find key project files + assert (project_root / "app").exists() + assert (project_root / "config.yaml").exists() + + def test_get_project_root_fallback(self, tmp_path, monkeypatch): + """Test get_project_root fallback mechanism""" + # Create a temporary directory structure + temp_project = tmp_path / "project" + temp_project.mkdir() + + # Create the config file + config_file = temp_project / "config.yaml" + config_file.write_text("test: config") + + # Create nested directories + deep_path = temp_project / "a" / "b" / "c" + deep_path.mkdir(parents=True) + + # Mock the __file__ attribute to simulate being in the deep path + mock_file_path = deep_path / "mock_file.py" + + # Temporarily patch the __file__ attribute in Profile module + monkeypatch.setattr("app.utils.profile.__file__", str(mock_file_path)) + + # Now import Profile after patching + from app.utils.profile import Profile + + # Test that we can find the project root + project_root = Profile.get_project_root() + assert project_root == temp_project \ No newline at end of file diff --git a/src/backend/tests/utils/test_sql_dialect_converter.py b/src/backend/tests/utils/test_sql_dialect_converter.py new file mode 100644 index 0000000..b5420eb --- /dev/null +++ b/src/backend/tests/utils/test_sql_dialect_converter.py @@ -0,0 +1,70 @@ +""" +SQL方言转换工具测试 +""" + +import pytest +from app.utils.sql_dialect_converter import SQLDialectConverter, SQLConversionError + + +class TestSQLDialectConverter: + """SQL方言转换器测试类""" + + def setup_method(self): + """测试初始化""" + self.converter = SQLDialectConverter() + + def test_supported_dialects(self): + """测试支持的方言""" + dialects = self.converter.get_supported_dialects() + assert 'mysql' in dialects + assert 'postgresql_database' in dialects + assert 'sqlite' in dialects + + def test_mysql_to_postgresql_conversion(self): + """测试MySQL到PostgreSQL转换""" + mysql_sql = "SELECT DATE_FORMAT(NOW(), '%Y-%m-%d') AS today" + postgresql_sql = self.converter.convert(mysql_sql, 'mysql', 'postgresql_database') + # PostgreSQL使用TO_CHAR函数而不是DATE_FORMAT + assert 'TO_CHAR' in postgresql_sql + assert 'NOW()' in postgresql_sql + + def test_basic_select_conversion(self): + """测试基本SELECT语句转换""" + sql = "SELECT id, name FROM users WHERE age > 18" + # MySQL到PostgreSQL + converted = self.converter.convert(sql, 'mysql', 'postgresql_database') + assert 'SELECT' in converted + assert 'FROM' in converted + assert 'WHERE' in converted + + def test_invalid_dialect(self): + """测试无效方言""" + with pytest.raises(ValueError): + self.converter.convert("SELECT * FROM users", 'mysql', 'unsupported_dialect') + + def test_invalid_sql(self): + """测试无效SQL""" + invalid_sql = "SELECT * FROM users WHERE" + with pytest.raises(SQLConversionError): + self.converter.convert(invalid_sql, 'mysql', 'postgresql_database') + + def test_sql_validation(self): + """测试SQL验证""" + valid_sql = "SELECT * FROM users WHERE age > 18" + invalid_sql = "SELECT * FROM users WHERE" + + assert self.converter.validate_sql(valid_sql, 'mysql') is True + assert self.converter.validate_sql(invalid_sql, 'mysql') is False + + def test_sql_formatting(self): + """测试SQL格式化""" + unformatted_sql = "SELECT id,name,email FROM users WHERE age>18 ORDER BY name" + formatted_sql = self.converter.format_sql(unformatted_sql, 'mysql') + + # 格式化后的SQL应该包含适当的空格 + assert 'id, ' in formatted_sql or 'id,' in formatted_sql + assert 'age > 18' in formatted_sql or 'age>18' not in formatted_sql + + +if __name__ == "__main__": + pytest.main([__file__]) \ No newline at end of file diff --git a/src/frontend/.env.development b/src/frontend/.env.development new file mode 100644 index 0000000..486ecc0 --- /dev/null +++ b/src/frontend/.env.development @@ -0,0 +1 @@ +VITE_USE_MOCK=true \ No newline at end of file diff --git a/src/frontend/.gitignore b/src/frontend/.gitignore new file mode 100644 index 0000000..c9e575d --- /dev/null +++ b/src/frontend/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + + +.kiro/* + diff --git a/src/frontend/Dockerfile.dev b/src/frontend/Dockerfile.dev new file mode 100644 index 0000000..f6860d5 --- /dev/null +++ b/src/frontend/Dockerfile.dev @@ -0,0 +1,14 @@ +# ./src/frontend/Dockerfile.dev +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ + +# 1. 🟢 设置淘宝/阿里云镜像源(大幅提升下载速度) +RUN npm config set registry https://registry.npmmirror.com/ + +# 2. 🟢 换成 npm install 并不再使用 silent(容错率高,且能看到进度) +RUN npm install + +CMD ["npm", "run", "dev"] \ No newline at end of file diff --git a/src/frontend/README.md b/src/frontend/README.md new file mode 100644 index 0000000..f35d09f --- /dev/null +++ b/src/frontend/README.md @@ -0,0 +1,203 @@ +# Frontend 前端服务 + +AI 数据库自动部署系统 - 基于 React 19 + TypeScript + Vite 的现代化前端应用。 + +## 技术栈 + +| 技术 | 版本 | 用途 | +|------|------|------| +| React | 19.2.0 | UI 框架 | +| TypeScript | 5.8.x | 类型安全 | +| Vite | 6.2.0 | 构建工具 | +| React Router | 7.11.0 | 路由管理 | +| Axios | 1.13.2 | HTTP 客户端 | +| Recharts | 3.4.1 | 数据图表 | +| Mermaid | 11.12.2 | 流程图渲染 | +| Lucide React | 0.554.0 | 图标库 | + +## 目录结构 + +``` +src/frontend/ +├── public/ # 静态资源 +├── scripts/ # 构建脚本 +│ └── check-compilation.ts +├── src/ +│ ├── api/ # API 接口封装 +│ ├── components/ # 通用组件 +│ ├── constants/ # 常量定义 +│ ├── hooks/ # 自定义 Hooks +│ ├── pages/ # 页面组件 +│ │ ├── Admin.tsx # 管理后台 +│ │ ├── AdminAIModels.tsx # AI 模型管理 +│ │ ├── AdminAnnouncements.tsx # 公告管理 +│ │ ├── AdminStatus.tsx # 系统状态 +│ │ ├── Announcements.tsx # 公告页 +│ │ ├── Dashboard.tsx # 仪表盘 +│ │ ├── DatabaseViewer.tsx # 数据库查看器 +│ │ ├── Glossary.tsx # 术语表 +│ │ ├── Login.tsx # 登录页 +│ │ ├── Profile.tsx # 用户配置 +│ │ ├── Reports.tsx # 报表页 +│ │ ├── UserProfile.tsx # 用户资料 +│ │ ├── Workspace.tsx # 工作空间 +│ │ └── WorkspaceWrapper.tsx # 工作空间包装器 +│ ├── routes/ # 路由配置 +│ ├── types/ # TypeScript 类型定义 +│ ├── utils/ # 工具函数 +│ ├── App.tsx # 应用入口组件 +│ ├── index.tsx # 应用入口文件 +│ ├── index.css # 全局样式 +│ └── types.ts # 类型定义 +├── index.html # HTML 模板 +├── package.json # 依赖配置 +├── tsconfig.json # TypeScript 配置 +├── tsconfig.node.json # Node TypeScript 配置 +├── vite.config.ts # Vite 配置 +├── vite-env.d.ts # Vite 环境类型声明 +└── Dockerfile.dev # Docker 开发镜像 +``` + +## 快速开始 + +### 环境要求 + +- Node.js >= 20.x +- npm >= 10.x + +### 1. 安装依赖 + +```bash +# 进入前端目录 +cd src/frontend + +# 配置国内镜像源(可选,提升下载速度) +npm config set registry https://registry.npmmirror.com/ + +# 安装依赖 +npm install +``` + +### 2. 启动开发服务器 + +```bash +npm run dev +``` + +开发服务器启动后,访问:http://localhost:3000 + +### 3. 构建生产版本 + +```bash +npm run build +``` + +构建产物输出到 `dist/` 目录。 + +## 开发指南 + +### API 代理配置 + +开发环境下,前端通过 Vite 代理将 `/api` 请求转发到后端服务: + +```typescript +// vite.config.ts +proxy: { + '/api': { + target: 'http://localhost:8000', // 本地后端 + // target: 'http://host.docker.internal:8000', // Docker 环境 + changeOrigin: true, + } +} +``` + +### 添加新页面 + +1. 在 `src/pages/` 下创建页面组件 +2. 在 `src/routes/` 中注册路由 + +```tsx +// src/pages/MyPage.tsx +import React from 'react'; + +const MyPage: React.FC = () => { + return
My New Page
; +}; + +export default MyPage; +``` + +### 添加 API 接口 + +在 `src/api/` 目录下封装 API 调用: + +```typescript +// src/api/myApi.ts +import axios from 'axios'; + +export const getMyData = async () => { + const response = await axios.get('/api/v1/my-endpoint'); + return response.data; +}; +``` + +## 常用命令 + +| 命令 | 说明 | +|------|------| +| `npm run dev` | 启动开发服务器(热更新) | +| `npm run build` | 构建生产版本 | +| `npm run preview` | 预览构建结果 | +| `npm run type-check` | TypeScript 类型检查 | + +## Docker 开发 + +使用 Docker Compose 启动(推荐): + +```bash +# 在项目根目录执行 +docker-compose up -d frontend + +# 查看日志 +docker-compose logs -f frontend +``` + +前端服务将在 http://localhost:3000 启动。 + +## 常见问题 + +### Q: npm install 很慢或失败 + +配置国内镜像源: + +```bash +npm config set registry https://registry.npmmirror.com/ +``` + +### Q: 修改代码后页面没有更新 + +1. 确保开发服务器正在运行 +2. 检查浏览器控制台是否有错误 +3. 尝试清除浏览器缓存或强制刷新(Ctrl+Shift+R) + +### Q: API 请求失败 + +1. 确保后端服务正在运行(http://localhost:8000) +2. 检查 Vite 代理配置是否正确 +3. 检查浏览器网络面板查看具体错误 + +### Q: Docker 环境下文件修改不触发热更新 + +Docker 中使用轮询方式监听文件变化,已在 `vite.config.ts` 中配置: + +```typescript +watch: { + usePolling: true, +} +``` + +## 相关文档 + +- [后端 README](../backend/README.md) +- [部署启动文档](../../部署启动文档.md) +- [API 文档](http://localhost:8000/docs) diff --git a/src/frontend/index.html b/src/frontend/index.html new file mode 100644 index 0000000..ecb3a2e --- /dev/null +++ b/src/frontend/index.html @@ -0,0 +1,43 @@ + + + + + + + 基于大模型多智能体框架的数据库自动部署与库表生成 + + + + + + +
+ + + \ No newline at end of file diff --git a/src/frontend/metadata.json b/src/frontend/metadata.json new file mode 100644 index 0000000..856cd93 --- /dev/null +++ b/src/frontend/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "AI-DB Auto Deploy", + "description": "An intelligent database management platform powered by Gemini, allowing natural language interaction for schema generation, querying, and analysis.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json new file mode 100644 index 0000000..03b25ee --- /dev/null +++ b/src/frontend/package-lock.json @@ -0,0 +1,4521 @@ +{ + "name": "ai-db-auto-deploy", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-db-auto-deploy", + "version": "0.0.0", + "dependencies": { + "@google/genai": "^1.30.0", + "axios": "^1.13.2", + "html-to-image": "^1.11.13", + "lucide-react": "^0.554.0", + "mermaid": "^11.12.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.11.0", + "recharts": "^3.4.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.2.6", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", + "integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz", + "integrity": "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.20.1" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", + "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz", + "integrity": "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.2.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", + "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.47", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", + "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001756", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", + "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, + "node_modules/chevrotain/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", + "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.259", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz", + "integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", + "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/katex": { + "version": "0.16.27", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", + "integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", + "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.554.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz", + "integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mermaid": { + "version": "11.12.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", + "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.1", + "@mermaid-js/parser": "^0.6.3", + "@types/d3": "^7.4.3", + "cytoscape": "^3.29.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.13", + "dayjs": "^1.11.18", + "dompurify": "^3.2.5", + "katex": "^0.16.22", + "khroma": "^2.1.0", + "lodash-es": "^4.17.21", + "marked": "^16.2.1", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-is": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", + "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz", + "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==", + "license": "MIT", + "dependencies": { + "react-router": "7.11.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.4.1.tgz", + "integrity": "sha512-35kYg6JoOgwq8sE4rhYkVWwa6aAIgOtT+Ob0gitnShjwUwZmhrmy7Jco/5kJNF4PnLXgt9Hwq+geEMS+WrjU1g==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/frontend/package.json b/src/frontend/package.json new file mode 100644 index 0000000..e187868 --- /dev/null +++ b/src/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "ai-db-auto-deploy", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@google/genai": "^1.30.0", + "axios": "^1.13.2", + "html-to-image": "^1.11.13", + "lucide-react": "^0.554.0", + "mermaid": "^11.12.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.11.0", + "recharts": "^3.4.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.2.6", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/src/frontend/public/background.svg b/src/frontend/public/background.svg new file mode 100644 index 0000000..89c2597 --- /dev/null +++ b/src/frontend/public/background.svg @@ -0,0 +1,69 @@ + + + + Group 21 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/public/database-logo.svg b/src/frontend/public/database-logo.svg new file mode 100644 index 0000000..1aa60c4 --- /dev/null +++ b/src/frontend/public/database-logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/public/mysql-logo.svg b/src/frontend/public/mysql-logo.svg new file mode 100644 index 0000000..cb2d508 --- /dev/null +++ b/src/frontend/public/mysql-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/public/postgresql-logo.svg b/src/frontend/public/postgresql-logo.svg new file mode 100644 index 0000000..bd2103e --- /dev/null +++ b/src/frontend/public/postgresql-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/public/sqlite-logo.svg b/src/frontend/public/sqlite-logo.svg new file mode 100644 index 0000000..c8703c1 --- /dev/null +++ b/src/frontend/public/sqlite-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/scripts/check-compilation.ts b/src/frontend/scripts/check-compilation.ts new file mode 100644 index 0000000..b507465 --- /dev/null +++ b/src/frontend/scripts/check-compilation.ts @@ -0,0 +1,218 @@ +#!/usr/bin/env node + +/** + * TypeScript Compilation Check Script + * This script verifies that all TypeScript files compile without errors + * and provides detailed reporting of any issues found. + */ + +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import path from 'path'; + +interface CompilationResult { + success: boolean; + errors: string[]; + warnings: string[]; + totalFiles: number; + errorCount: number; +} + +class TypeScriptCompilationChecker { + private projectRoot: string; + private tsconfigPath: string; + + constructor() { + this.projectRoot = process.cwd(); + this.tsconfigPath = path.join(this.projectRoot, 'tsconfig.json'); + } + + /** + * Check if TypeScript configuration exists + */ + private checkTsConfig(): boolean { + if (!existsSync(this.tsconfigPath)) { + console.error('❌ tsconfig.json not found in project root'); + return false; + } + console.log('✅ Found tsconfig.json'); + return true; + } + + /** + * Run TypeScript compiler and capture output + */ + private runTypeScriptCompiler(): CompilationResult { + try { + console.log('🔍 Running TypeScript compiler...'); + + // Run tsc with noEmit flag to only check types + const output = execSync('npx tsc --noEmit --pretty false', { + encoding: 'utf8', + cwd: this.projectRoot + }); + + return { + success: true, + errors: [], + warnings: [], + totalFiles: 0, + errorCount: 0 + }; + + } catch (error: any) { + const output = error.stdout || error.message || ''; + const lines = output.split('\n').filter((line: string) => line.trim()); + + const errors: string[] = []; + const warnings: string[] = []; + let errorCount = 0; + + lines.forEach((line: string) => { + if (line.includes(' error TS')) { + errors.push(line); + errorCount++; + } else if (line.includes(' warning TS')) { + warnings.push(line); + } + }); + + return { + success: false, + errors, + warnings, + totalFiles: 0, + errorCount + }; + } + } + + /** + * Categorize errors by type + */ + private categorizeErrors(errors: string[]): Record { + const categories: Record = { + 'Unused Variables': [], + 'Type Errors': [], + 'Import Errors': [], + 'Property Errors': [], + 'Mock Errors': [], + 'Other': [] + }; + + errors.forEach(error => { + if (error.includes('is declared but its value is never read')) { + categories['Unused Variables'].push(error); + } else if (error.includes('Type ') || error.includes('not assignable')) { + categories['Type Errors'].push(error); + } else if (error.includes('import') || error.includes('module')) { + categories['Import Errors'].push(error); + } else if (error.includes('Property') || error.includes('does not exist')) { + categories['Property Errors'].push(error); + } else if (error.includes('mock') || error.includes('Mock')) { + categories['Mock Errors'].push(error); + } else { + categories['Other'].push(error); + } + }); + + return categories; + } + + /** + * Generate compilation report + */ + private generateReport(result: CompilationResult): void { + console.log('\n📊 TypeScript Compilation Report'); + console.log('================================'); + + if (result.success) { + console.log('✅ All TypeScript files compiled successfully!'); + console.log('🎉 No type errors found.'); + return; + } + + console.log(`❌ Compilation failed with ${result.errorCount} errors`); + + if (result.warnings.length > 0) { + console.log(`⚠️ ${result.warnings.length} warnings found`); + } + + // Categorize and display errors + const categorizedErrors = this.categorizeErrors(result.errors); + + Object.entries(categorizedErrors).forEach(([category, errors]) => { + if (errors.length > 0) { + console.log(`\n📂 ${category} (${errors.length}):`); + errors.slice(0, 5).forEach(error => { + console.log(` ${error}`); + }); + if (errors.length > 5) { + console.log(` ... and ${errors.length - 5} more`); + } + } + }); + + // Provide recommendations + this.provideRecommendations(categorizedErrors); + } + + /** + * Provide recommendations based on error types + */ + private provideRecommendations(categorizedErrors: Record): void { + console.log('\n💡 Recommendations:'); + + if (categorizedErrors['Unused Variables'].length > 0) { + console.log('• Remove unused variables or prefix with underscore (_variable)'); + } + + if (categorizedErrors['Type Errors'].length > 0) { + console.log('• Review type definitions and ensure proper type annotations'); + } + + if (categorizedErrors['Property Errors'].length > 0) { + console.log('• Check object property names and interface definitions'); + } + + if (categorizedErrors['Mock Errors'].length > 0) { + console.log('• Update test mocks to match current API signatures'); + } + + console.log('• Consider using TypeScript strict mode for better type safety'); + console.log('• Run "npx tsc --noEmit" for detailed error information'); + } + + /** + * Main execution method + */ + public async run(): Promise { + console.log('🚀 Starting TypeScript Compilation Check...\n'); + + // Check if tsconfig exists + if (!this.checkTsConfig()) { + return false; + } + + // Run compilation check + const result = this.runTypeScriptCompiler(); + + // Generate report + this.generateReport(result); + + return result.success; + } +} + +// Execute if run directly +if (require.main === module) { + const checker = new TypeScriptCompilationChecker(); + checker.run().then(success => { + process.exit(success ? 0 : 1); + }).catch(error => { + console.error('❌ Compilation check failed:', error); + process.exit(1); + }); +} + +export default TypeScriptCompilationChecker; \ No newline at end of file diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx new file mode 100644 index 0000000..3914a70 --- /dev/null +++ b/src/frontend/src/App.tsx @@ -0,0 +1,312 @@ +import React, { useState, useEffect } from 'react'; +import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; +import { Sidebar } from './components/Sidebar.tsx'; +import { Header } from './components/Header.tsx'; +import { ToastContainer } from './components/UI.tsx'; +import { Login } from './pages/Login.tsx'; +import { Dashboard } from './pages/Dashboard.tsx'; +import { WorkspaceWrapper } from './pages/WorkspaceWrapper.tsx'; +import { AdminPanel } from './pages/Admin.tsx'; +import { AdminAnnouncements } from './pages/AdminAnnouncements.tsx'; +import { UserProfile } from './pages/UserProfile.tsx'; +import { Reports } from './pages/Reports.tsx'; +import { Glossary } from './pages/Glossary.tsx'; +import { Profile } from './pages/Profile.tsx'; +import { AdminStatus } from './pages/AdminStatus.tsx'; +import { AdminAIModels } from './pages/AdminAIModels.tsx'; +import { Announcements } from './pages/Announcements.tsx'; +import { UserRole, Project, User } from './types.ts'; +import { authApi } from './api/auth.ts'; +import { getUserProfile } from './api/user.ts'; +import { ProjectDTO } from './api/project.ts'; +import { Loader2 } from 'lucide-react'; +import { ROUTES, getActivePageFromPath, generatePath } from './routes/index.tsx'; + +const App: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + + // 认证状态 + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [currentUser, setCurrentUser] = useState<{ role: UserRole, name: string, avatar_url?: string } | null>(null); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + // Shared State + const [projects] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + const [viewingUser, setViewingUser] = useState(null); + + // 根据当前路径计算 activePage(用于 Sidebar 高亮) + const activePage = getActivePageFromPath(location.pathname); + + // 初始化时检查登录状态 + useEffect(() => { + const initAuth = async () => { + const token = localStorage.getItem('access_token'); + + if (!token) { + setIsLoading(false); + return; + } + + try { + const res = await getUserProfile() as any; + + let userData = null; + if (res && res.user_id) { + userData = res; + } else if (res && res.data && res.data.user_id) { + userData = res.data; + } + + if (userData) { + const role = userData.is_admin ? UserRole.ADMIN : UserRole.USER; + setCurrentUser({ + role, + name: userData.username, + avatar_url: userData.avatar_url + }); + setIsAuthenticated(true); + + // 如果当前在登录页或根路径,跳转到默认页面 + if (location.pathname === '/login' || location.pathname === '/') { + navigate(role === UserRole.ADMIN ? ROUTES.ADMIN_USERS : ROUTES.DASHBOARD, { replace: true }); + } + } else { + throw new Error('Invalid user data'); + } + } catch (error) { + console.warn('Auto login failed (Token expired or invalid):', error); + localStorage.removeItem('access_token'); + setIsAuthenticated(false); + setCurrentUser(null); + } finally { + setIsLoading(false); + } + }; + + initAuth(); + }, []); + + const handleLogin = (role: UserRole, username: string, avatar_url?: string) => { + setIsAuthenticated(true); + setCurrentUser({ role, name: username, avatar_url }); + navigate(role === UserRole.ADMIN ? ROUTES.ADMIN_USERS : ROUTES.DASHBOARD, { replace: true }); + }; + + const updateCurrentUser = (updates: Partial<{ name: string; avatar_url: string }>) => { + setCurrentUser(prev => prev ? { ...prev, ...updates } : null); + }; + + const handleLogout = async () => { + try { + await authApi.logout(); + } catch (error) { + console.warn('Logout API failed:', error); + } finally { + localStorage.removeItem('access_token'); + setIsAuthenticated(false); + setCurrentUser(null); + setSelectedProject(null); + setViewingUser(null); + navigate(ROUTES.LOGIN, { replace: true }); + } + }; + + // 设置选中项目(供 WorkspaceWrapper 使用) + const handleSetSelectedProject = (project: Project) => { + setSelectedProject(project); + }; + + const handleProjectSelect = (projectDTO: ProjectDTO) => { + const dbTypeMap: Record = { + 'mysql': 'MySQL', + 'postgresql': 'PostgreSQL', + 'sqlite': 'SQLite' + }; + + const statusMap: Record = { + 'initializing': 'deploying', + 'pending_confirmation': 'deploying', + 'active': 'active', + 'deleted': 'deleted', + 'error': 'error' + }; + + const project: Project = { + id: projectDTO.project_id.toString(), + name: projectDTO.project_name, + type: dbTypeMap[projectDTO.db_type.toLowerCase()] || 'MySQL', + description: projectDTO.description, + status: statusMap[projectDTO.project_status.toLowerCase()] || 'active', + createdAt: projectDTO.created_at + }; + + setSelectedProject(project); + navigate(generatePath(ROUTES.WORKSPACE, { projectId: project.id })); + }; + + const handleViewUser = (user: User) => { + setViewingUser(user); + navigate(generatePath(ROUTES.ADMIN_USER_DETAIL, { userId: user.id })); + }; + + // 页面导航处理(供 Sidebar 使用) + const handleNavigate = (page: string) => { + if (page !== 'admin_user_detail') setViewingUser(null); + + switch (page) { + case 'dashboard': + navigate(ROUTES.DASHBOARD); + break; + case 'workspace': + if (selectedProject) { + navigate(generatePath(ROUTES.WORKSPACE, { projectId: selectedProject.id })); + } else { + navigate(ROUTES.DASHBOARD); + } + break; + case 'reports': + navigate(ROUTES.REPORTS); + break; + case 'glossary': + navigate(ROUTES.GLOSSARY); + break; + case 'announcements': + navigate(ROUTES.ANNOUNCEMENTS); + break; + case 'profile': + navigate(ROUTES.PROFILE); + break; + case 'admin_users': + navigate(ROUTES.ADMIN_USERS); + break; + case 'admin_announcements': + navigate(ROUTES.ADMIN_ANNOUNCEMENTS); + break; + case 'admin_ai_models': + navigate(ROUTES.ADMIN_AI_MODELS); + break; + case 'admin_status': + navigate(ROUTES.ADMIN_STATUS); + break; + default: + navigate(ROUTES.DASHBOARD); + } + setIsSidebarOpen(false); + }; + + // 返回 Dashboard 的处理 + const handleBackToDashboard = () => { + setSelectedProject(null); + navigate(ROUTES.DASHBOARD); + }; + + // 返回用户列表的处理 + const handleBackToUserList = () => { + setViewingUser(null); + navigate(ROUTES.ADMIN_USERS); + }; + + // 1. 如果正在检查 Token,显示全局 Loading + if (isLoading) { + return ( +
+
+ +

正在恢复会话...

+
+
+ ); + } + + // 2. 如果未认证,只显示登录页 + if (!isAuthenticated) { + return ( + <> + + + } /> + } /> + + + ); + } + + // 3. 已认证,渲染主布局 + 路由 + const isAdmin = currentUser?.role === UserRole.ADMIN; + const defaultRoute = isAdmin ? ROUTES.ADMIN_USERS : ROUTES.DASHBOARD; + + return ( +
+ + + setIsSidebarOpen(false)} + onLogout={handleLogout} + onNavigate={handleNavigate} + /> +
+
setIsSidebarOpen(!isSidebarOpen)} /> +
+ + {/* 公共路由 - 两种角色都可访问 */} + } /> + + {/* 普通用户路由 - 仅非管理员可访问 */} + {!isAdmin && ( + <> + } /> + + } + /> + } /> + } /> + } /> + + )} + + {/* 管理员路由 - 仅管理员可访问 */} + {isAdmin && ( + <> + } /> + + ) : ( + + ) + } + /> + } /> + } /> + } /> + + )} + + {/* 默认路由 & 未匹配路由 - 重定向到角色对应首页 */} + } /> + } /> + +
+
+
+ ); +}; + +export default App; diff --git a/src/frontend/src/api/admin.ts b/src/frontend/src/api/admin.ts new file mode 100644 index 0000000..9ce5a58 --- /dev/null +++ b/src/frontend/src/api/admin.ts @@ -0,0 +1,235 @@ +/** + * @file admin.ts + * @module API/Admin + * @description 系统管理员专用 API 服务模块。 + * 本模块深度集成于“基于大模型多智能体框架的数据库自动部署与库表生成系统”的管理后台, + * 负责处理高权限等级的操作指令,包括但不限于全局状态监控、用户生命周期管理、 + * 资源配额动态调整以及底层 AI 代理模型的参数化配置。 + * @author Wang Lirong (王利蓉) + * @version 2.1.0 + * @date 2026-01-02 + */ + +import client from './client.ts'; +import { + AdminStats, + AdminUserDetailResponse, + PageData, + ViolationLogListItem, + AdminUserListItem, + AdminListItem, + AIModelConfigResponse, + AIModelConfigListResponse, + AIModelConfigDetailResponse, + AIModelConfigCreate, + AIModelConfigUpdate, + AIModelTestConnectionRequest, + AIModelTestConnectionResponse +} from '../types.ts'; + +/** + * 更新用户状态时的请求参数接口定义 + * @interface UpdateUserStatusParams + * @property {string} status - 目标状态,可选值:正常(normal)、封禁(banned)、挂起(suspended) + * @property {string} reason - 变更状态的操作原因说明,用于审计日志记录 + */ +interface UpdateUserStatusParams { + status: 'normal' | 'banned' | 'suspended'; + reason: string; +} + +/** + * 调整用户资源额度时的请求参数接口定义 + * @interface UpdateUserQuotaParams + * [cite_start]@property {number} max_databases - 允许该用户创建的最大数据库项目上限 [cite: 518] + */ +interface UpdateUserQuotaParams { + max_databases: number; +} + +/** + * 管理员 API 核心调用对象 + * [cite_start]封装了所有涉及系统治理与运维的 RESTful 交互逻辑 [cite: 170-173, 218-219] + */ +export const adminApi = { + /** + * 1.1 获取系统统计看板数据 + * 从后端 APM 监控模块调取实时数据,包含系统健康度、活跃用户及高危拦截统计。 + * [cite_start]对应业务目标 BO-4 的系统监控需求 [cite: 55-60, 253]。 + * @method getDashboardStats + * @returns {Promise} 包含全局运行指标的响应对象 + */ + getDashboardStats: () => { + return client.get('/v1/dashboard/stats'); + }, + + /** + * 2.1 分页获取全平台用户列表 + * [cite_start]允许管理员检索注册用户清单,并根据活跃状态、用户名或邮箱进行筛选 [cite: 484-487, 496]。 + * @method getUsers + * @param {number} [page=1] - 请求页码 + * @param {number} [pageSize=20] - 每页数据量 + * @param {string} [search] - 搜索关键词 + * @param {string} [status='all'] - 状态过滤 + */ + getUsers: (page: number = 1, pageSize: number = 20, search?: string, status: string = 'all') => { + return client.get>('/v1/users', { + params: { + page, + page_size: pageSize, + search, + status + } + }); + }, + + /** + * 2.4 获取特定用户的详细档案 + * [cite_start]调阅包含用户基本信息、项目清单及最近登录记录的完整档案 [cite: 488-489]。 + * @method getUserDetail + * @param {number | string} userId - 用户唯一标识 + * @param {number} [projectLimit=100] - 项目展示限制 + * @param {number} [loginLimit=20] - 登录记录展示限制 + */ + getUserDetail: (userId: number | string, projectLimit: number = 100, loginLimit: number = 20) => { + return client.get(`/v1/users/${userId}`, { + params: { + project_limit: projectLimit, + login_limit: loginLimit + } + }); + }, + + /** + * 2.2 修改用户状态 (封禁/解封) + * [cite_start]针对违规账号执行状态变更,封禁操作将强制清除用户会话 [cite: 490-493, 617]。 + * @method updateUserStatus + * @param {number | string} userId - 目标用户 ID + * @param {UpdateUserStatusParams} data - 状态变更数据 + */ + updateUserStatus: (userId: number | string, data: UpdateUserStatusParams) => { + return client.patch(`/v1/users/${userId}/status`, data); + }, + + /** + * 2.3 调整用户资源额度 + * [cite_start]根据分配策略手动调整用户允许创建的数据库项目上限 [cite: 509, 518]。 + * @method updateUserQuota + * @param {number | string} userId - 目标用户 ID + * @param {number} maxDatabases - 新的配额上限值 + */ + updateUserQuota: (userId: number | string, maxDatabases: number) => { + const data: UpdateUserQuotaParams = { max_databases: maxDatabases }; + return client.patch(`/v1/users/${userId}/quota`, data); + }, + + /** + * 4.1 获取管理员列表 + * [cite_start]调阅系统中所有管理账户的实时在线状态及最后登录时间 [cite: 499, 506]。 + * @method getAdmins + * @param {number} [page=1] + * @param {number} [pageSize=20] + */ + getAdmins: (page: number = 1, pageSize: number = 20) => { + return client.get>('/v1/admins', { + params: { + page, + page_size: pageSize + } + }); + }, + + /** + * 4.2 获取全平台违规记录列表 + * [cite_start]查看系统安全审计引擎捕获的违规行为或异常 SQL 操作记录 [cite: 107, 218]。 + * @method getViolations + * @param {number} [page=1] + * @param {number} [pageSize=20] + * @param {string} [riskLevel] - 风险等级过滤 + * @param {string} [resolutionStatus] - 处理状态过滤 + */ + getViolations: ( + page: number = 1, + pageSize: number = 20, + riskLevel?: string, + resolutionStatus?: string + ) => { + return client.get>('/v1/violations', { + params: { + page, + page_size: pageSize, + risk_level: riskLevel, + resolution_status: resolutionStatus + } + }); + }, + + /** + * 5.1 获取 AI 模型配置列表 + * [cite_start]获取多智能体框架中已集成的 LLM 服务配置信息 [cite: 81-84, 287-292]。 + * @method getAIModels + * @param {number} [page=1] + * @param {number} [pageSize=20] + * @param {boolean} [activeOnly=false] + */ + getAIModels: (page: number = 1, pageSize: number = 20, activeOnly: boolean = false) => { + return client.get('/v1/ai-models', { + params: { + page, + page_size: pageSize, + active_only: activeOnly + } + }); + }, + + /** + * 5.2 获取 AI 模型配置详情 + * 查看特定模型配置的推理参数及 API 连接详情。 + * @method getAIModelDetail + * @param {number} configId - 配置项唯一识别码 + */ + getAIModelDetail: (configId: number) => { + return client.get(`/v1/ai-models/${configId}`); + }, + + /** + * 5.3 创建 AI 模型配置 + * 向系统注册新的大语言模型服务实例。 + * @method createAIModel + * @param {AIModelConfigCreate} data - 模型接入配置信息 + */ + createAIModel: (data: AIModelConfigCreate) => { + return client.post('/v1/ai-models', data); + }, + + /** + * 5.4 更新 AI 模型配置 + * 修改已注册模型的 API 密钥、端点或模型权重参数。 + * @method updateAIModel + * @param {number} configId - 目标配置 ID + * @param {AIModelConfigUpdate} data - 待更新的数据包 + */ + updateAIModel: (configId: number, data: AIModelConfigUpdate) => { + return client.put(`/v1/ai-models/${configId}`, data); + }, + + /** + * 5.5 删除 AI 模型配置 + * 从系统配置库中物理移除指定的 AI 模型接入点。 + * @method deleteAIModel + * @param {number} configId - 待删除的配置 ID + */ + deleteAIModel: (configId: number) => { + return client.delete(`/v1/ai-models/${configId}`); + }, + + /** + * 5.6 测试 AI 模型连接 + * 通过发送握手请求验证当前 AI 模型配置的连通性及授权有效性。 + * @method testAIModelConnection + * @param {AIModelTestConnectionRequest} data - 临时测试连接数据 + */ + testAIModelConnection: (data: AIModelTestConnectionRequest) => { + return client.post('/v1/ai-models/test-connection', data); + } +}; \ No newline at end of file diff --git a/src/frontend/src/api/announcement.ts b/src/frontend/src/api/announcement.ts new file mode 100644 index 0000000..554bf85 --- /dev/null +++ b/src/frontend/src/api/announcement.ts @@ -0,0 +1,135 @@ +/** + * @file announcement.ts + * @module API/Announcement + * @description 系统公告管理 API 模块。 + * 本模块负责系统全局通知的发布与维护,用于向用户传达系统维护、版本更新及重要政策变动。 + * 根据项目需求,系统公告支持全生命周期管理,包括草稿保存、立即发布及撤回功能 [cite: 521]。 + * @author Wang Lirong (王利蓉) + * @version 1.3.0 + * @date 2026-01-02 + */ + +import client from './client.ts'; +import { Announcement } from '../types.ts'; + +/** + * 公告列表响应接口定义 + * @interface AnnouncementListResponse + * @description 封装分页查询公告列表时的标准返回结构 [cite: 525, 533] + * @property {number} total - 符合过滤条件的公告总数 + * @property {number} page - 当前返回的数据页码 + * @property {number} page_size - 单页数据容量 + * @property {Announcement[]} items - 包含公告详细对象的数组 + */ +interface AnnouncementListResponse { + total: number; + page: number; + page_size: number; + items: Announcement[]; +} + +/** + * 创建公告的请求参数接口 + * @export @interface CreateAnnouncementParams + * @description 管理员创建新公告时所需的最小数据集 [cite: 526, 533] + * @property {string} title - 公告标题,需精炼表达通知核心内容 + * @property {string} content - 公告正文,支持详细的业务逻辑或维护说明 + * @property {string} [status] - 初始状态:'published' (立即发布) 或 'draft' (存为草稿) + */ +export interface CreateAnnouncementParams { + title: string; + content: string; + status?: 'published' | 'draft'; +} + +/** + * 更新公告的请求参数接口 + * @export @interface UpdateAnnouncementParams + * @description 允许对现有公告进行增量或全量修改的参数集合 [cite: 533] + * @property {string} [title] - 修改后的标题 + * @property {string} [content] - 修改后的公告内容 + * @property {string} [status] - 变更后的公告状态:发布、草稿、撤下或过期 + */ +export interface UpdateAnnouncementParams { + title?: string; + content?: string; + status?: 'published' | 'draft' | 'unpublished' | 'expired'; +} + +/** + * 公告 API 核心调用对象 + * 支撑管理员端 (Admin.Announce) 与普通用户端 (SF12) 的通知交互需求 [cite: 108, 591] + */ +export const announcementApi = { + /** + * 4.2.1 在系统中创建新的公告条目 + * 管理员可通过此接口发布全局维护公告或功能更新通知。 + * 系统将根据 status 字段决定是否立即对全平台用户可见 [cite: 533]。 + * @method create + * @param {CreateAnnouncementParams} data - 包含标题、内容及发布状态的请求载荷 + * @returns {Promise} 返回后端生成的公告完整对象,包含自增 ID 和时间戳 + */ + create: (data: CreateAnnouncementParams) => { + // 发起 POST 请求至公告管理端点,执行入库操作 + return client.post('/v1/announcements', data); + }, + + /** + * 4.2.2 更新指定 ID 的公告内容或状态 + * 允许管理员修改已发布的公告文字,或将已发布的公告设为“已撤下”状态。 + * 撤下操作会使公告对普通用户立即失效 [cite: 533]。 + * @method update + * @param {number} id - 待更新公告的唯一识别 ID + * @param {UpdateAnnouncementParams} data - 包含待修改字段的更新对象 + * @returns {Promise} 返回更新后的公告对象 + */ + update: (id: number, data: UpdateAnnouncementParams) => { + // 使用 PUT 方法对公告资源执行全量/部分更新 + return client.put(`/v1/announcements/${id}`, data); + }, + + /** + * 4.2.3 物理删除系统公告 + * 从数据库中永久移除公告记录。通常用于清理过时的维护通知或测试数据 [cite: 533]。 + * @method delete + * @param {number} id - 目标公告的 ID + * @returns {Promise} + */ + delete: (id: number) => { + // 调用 DELETE 动词执行移除逻辑 + return client.delete(`/v1/announcements/${id}`); + }, + + /** + * 获取公告列表 (支持分页与状态过滤) + * 对于普通用户,通常仅调用 status='published' 的列表进行首页轮播或横幅展示 [cite: 530]。 + * 对于管理员,则返回包含草稿和撤下公告的全量列表 [cite: 525]。 + * @method getList + * @param {number} [page=1] - 当前请求页码 + * @param {number} [pageSize=10] - 每页展示的数据量,默认为 10 条 + * @param {string} [status='published'] - 过滤公告状态,默认为已发布 + * @returns {Promise} 包含分页元数据和公告数组的 Promise + */ + getList: (page: number = 1, pageSize: number = 10, status: string = 'published') => { + // 对应系统会自动调用 AnnouncementService.get_announcement_list 逻辑 [cite: 530] + return client.get('/v1/announcements/', { + params: { + page, + page_size: pageSize, + status + } + }); + }, + + /** + * 获取单条公告的详细内容全文 + * 当用户在首页点击公告标题或摘要后,调用此接口加载并展示完整详情页 [cite: 531]。 + * @method getDetail + * @param {number} id - 公告 ID + * @returns {Promise} 返回包含完整正文内容的公告对象 + */ + getDetail: (id: number) => { + // 调用公告详情接口,常用于前端弹窗或详情页面的数据渲染 + return client.get(`/v1/announcements/${id}`); + } +}; \ No newline at end of file diff --git a/src/frontend/src/api/auth.ts b/src/frontend/src/api/auth.ts new file mode 100644 index 0000000..2ea6f10 --- /dev/null +++ b/src/frontend/src/api/auth.ts @@ -0,0 +1,191 @@ +/** + * @file auth.ts + * @module API/Authentication + * @description 用户认证与账户安全 API 服务模块。 + * 本模块作为系统的统一身份验证入口,实现了基于 JWT (JSON Web Token) 的认证机制 [cite: 326]。 + * 核心功能涵盖:用户注册验证流、多因素登录认证、会话生命周期管理(SF10)以及 + * 基于 SMTP 邮件网关的密码找回与重置逻辑 [cite: 217, 237]。 + * @author Wang Lirong (王利蓉) + * @version 1.3.0 + * @date 2026-01-02 + */ + +import client from './client.ts'; + +// --- 类型定义 (参考 API 文档 V1.3) --- + +/** + * 2.3 登录请求参数接口 + * @interface LoginRequest + * @description 封装用户登录时提交的身份凭证数据。 + * @property {string} username - 用户的唯一登录识别码或注册用户名。 + * @property {string} password - 用户登录密码(前端传输前应符合基础长度策略)。 + */ +export interface LoginRequest { + username: string; + password: string; +} + +/** + * 用户核心信息结构 + * @interface UserMe + * @description 存储当前登录用户的完整业务画像,包括权限等级与资源配额 [cite: 221]。 + * @property {number} user_id - 用户在全球系统中的唯一主键 ID。 + * @property {string} username - 注册用户名。 + * @property {string} email - 已验证的电子邮箱地址。 + * @property {string} status - 账户活跃状态:正常、挂起或封禁 [cite: 490-493]。 + * @property {number} used_databases - 当前已创建的数据库项目总量,用于配额校验 [cite: 375]。 + * @property {number} max_databases - 系统允许该用户拥有的最大项目上限,默认通常为 10 [cite: 90, 226]。 + * @property {string | null} avatar_url - 用户头像的托管地址。 + * @property {boolean} is_admin - 角色标识:是否拥有系统管理员运维权限 [cite: 331]。 + * @property {string | null} last_login_at - 最近一次登录系统的 ISO 8601 时间戳 [cite: 506]。 + * @property {string} created_at - 账号创建(注册)的原始时间。 + */ +export interface UserMe { + user_id: number; + username: string; + email: string; + status: 'normal' | 'suspended' | 'banned'; + used_databases: number; + max_databases: number; + avatar_url: string | null; + is_admin: boolean; + last_login_at: string | null; + created_at: string; +} + +/** + * 2.3 登录响应数据结构 + * @interface LoginData + * @description 适配后端 UnifiedResponse 的标准负载结构。 + * @property {string} access_token - JWT 访问令牌,有效期为 120 分钟 [cite: 327]。 + * @property {string} token_type - 令牌类型,通常为 "Bearer"。 + * @property {UserMe} user - 返回当前登录用户的详细档案对象。 + */ +export interface LoginData { + access_token: string; + token_type: string; + user: UserMe; +} + +/** + * 2.2 注册请求参数接口 + * @interface RegisterRequest + * @description 封装用户通过邮箱验证码进行新账号注册所需的数据字段 [cite: 457]。 + * @property {string} username - 拟注册的用户名。 + * @property {string} email - 用于接收验证码并绑定账号的邮箱地址 [cite: 612]。 + * @property {string} password - 设置的符合安全策略的原始密码。 + * @property {string} confirm_password - 用于二次核对的确认密码字段。 + * @property {string} verification_code - 从邮件网关收到的 6 位或 8 位数字验证码。 + */ +export interface RegisterRequest { + username: string; + email: string; + password: string; + confirm_password: string; + verification_code: string; +} + +/** + * 2.2 注册成功的响应结构 + * @interface RegisterResponse + */ +export interface RegisterResponse { + user_id: number; + username: string; + email: string; + created_at: string; +} + +/** + * 忘记密码发起请求的参数接口 + * @interface ForgotPasswordRequest + */ +export interface ForgotPasswordRequest { + email: string; +} + +/** + * 结合验证码执行密码重置的请求接口 + * @interface ResetPasswordWithCodeRequest + */ +export interface ResetPasswordWithCodeRequest { + email: string; + verification_code: string; + new_password: string; + confirm_password: string; +} + +// --- API 方法定义 --- + +/** + * 认证模块 API 调用核心对象 + * 封装了所有涉及系统准入与账号治理的通讯逻辑 + */ +export const authApi = { + /** + * 2.1 向指定邮箱发送注册验证码 + * 本接口调用外部 SMTP 邮件网关。若服务商限制频率,可能影响准入效率 [cite: 237]。 + * @method sendRegisterCode + * @param {string} email - 目标接收邮箱 + */ + sendRegisterCode: (email: string) => { + return client.post('/v1/auth/register/send-code', { email }); + }, + + /** + * 2.2 提交用户注册申请 + * 系统将验证验证码的有效性并检查用户名/邮箱是否冲突。 + * 注册成功后将自动分配初始数据库额度 [cite: 561]。 + * @method register + * @param {RegisterRequest} data - 注册表单全量数据 + */ + register: (data: RegisterRequest) => { + return client.post('/v1/auth/register', data); + }, + + /** + * 2.3 用户登录验证 + * 验证用户凭证并返回访问令牌。登录成功后,前端应将会话存入持久化存储 [cite: 236]。 + * @method login + * @param {LoginRequest} data - 登录凭证(用户名与密码) + * @returns {Promise} 包含令牌及用户画像的响应数据 [cite: 326] + */ + login: (data: LoginRequest) => { + return client.post('/v1/auth/login', data); + }, + + /** + * 2.4 用户安全登出 + * 清除后端的认证状态。前端拦截器会自动在 Header 中附带 JWT Token。 + * 退出后将清除本地会话信息并跳转至登录页 [cite: 461]。 + * @method logout + */ + logout: () => { + // 调用登出接口执行服务端 Session 销毁逻辑 + return client.post('/v1/auth/logout'); + }, + + /** + * 忘记密码:发起验证码重置流程 + * 系统将检查邮箱是否存在,若存在则发送重置专用验证码 [cite: 245, 612]。 + * @method sendPasswordResetCode + * @param {string} email - 绑定的账户邮箱 + */ + sendPasswordResetCode: (email: string) => { + // 构造重置请求载荷 + const payload: ForgotPasswordRequest = { email }; + return client.post('/v1/auth/forgot-password', payload); + }, + + /** + * 重置密码:结合邮箱验证码完成最终修改 + * 验证通过后直接更新底层数据库加密存储的密码 [cite: 612]。 + * @method resetPasswordWithCode + * @param {ResetPasswordWithCodeRequest} data - 包含验证码及新密码的数据体 + */ + resetPasswordWithCode: (data: ResetPasswordWithCodeRequest) => { + // 提交重置逻辑,更新账户安全凭据 + return client.post('/v1/auth/reset-password', data); + }, +}; \ No newline at end of file diff --git a/src/frontend/src/api/client.ts b/src/frontend/src/api/client.ts new file mode 100644 index 0000000..7e09c73 --- /dev/null +++ b/src/frontend/src/api/client.ts @@ -0,0 +1,183 @@ +/** + * @file client.ts + * @module API/Client + * @description 全局网络请求客户端核心配置文件。 + * 本模块基于 Axios 库构建,作为“基于大模型多智能体框架的数据库自动部署与库表生成系统”的前后端通讯基石。 + * 核心功能包含: + * 1. 统一的 RESTful API 基础路径配置; + * 2. 自动化身份认证拦截(JWT Bearer Token 注入); + * 3. 响应状态码及业务逻辑错误的集中化分发处理; + * 4. 指数退避算法实现的自动化重试机制,确保在不稳定网络环境下的服务可用性。 + * @author Wang Lirong (王利蓉) + * @version 2.4.0 + * @date 2026-01-02 + */ + +import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { BusinessError } from '../types'; +import { GlobalErrorHandler } from '../utils/errorHandling'; +import { responseInterceptor, errorInterceptor } from './errorMiddleware'; + +/** + * 后端服务基础端点配置 + * 对应 Vite/Webpack 的代理配置前缀,统一路由至 /api/v1 路径下 + */ +const BASE_URL = '/api'; + +/** + * 初始化全局错误处理器实例 + * 配置项包括用户友好消息开关、错误日志归档以及预设的重试策略。 + * 旨在满足系统非功能性需求中的“可用性”与“可靠性”指标 [cite: 541, 544]。 + */ +const errorHandler = new GlobalErrorHandler({ + showUserFriendlyMessages: true, // 是否向非技术用户展示语义化的错误提示 [cite: 545] + logErrors: true, // 是否开启生产环境错误上报 + enableRetry: true, // 全局默认开启异常重试逻辑 + maxRetries: 3, // 最大尝试次数,避免无效请求死循环 + retryDelay: 1000, // 初始重试延迟基数(毫秒) +}); + +/** + * 创建 Axios 单例对象 + * 统一设置超时时间、基础路径及 Content-Type 策略。 + * 超时时间设定为 30 秒,以平衡 AI 模型推理的长连接需求与前端交互体验 [cite: 55-60]。 + */ +const client: AxiosInstance = axios.create({ + baseURL: BASE_URL, + timeout: 30000, // 30秒超时设定,针对复杂 DDL 生成及异步任务预留冗余 [cite: 238] + headers: { + 'Content-Type': 'application/json', + }, +}); + +/** + * --- 请求拦截器:自动携带身份验证凭据 --- + * 遵循 CON5 数据安全约束,系统在每次请求发出前自动检索并注入 JWT Token [cite: 227, 326]。 + */ +client.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + /** + * 从持久化存储 (localStorage) 中读取当前会话的访问令牌 + * 该令牌由 authApi.login 成功响应后写入。 + */ + const token = localStorage.getItem('access_token'); + + // 若存在有效令牌,则按照 RFC 6750 规范将其置于 Authorization 头部 + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; + }, + (error: AxiosError) => { + // 处理请求发送前的预检错误 + return Promise.reject(error); + } +); + +/** + * --- 响应拦截器:集成统一的错误处理中间件 --- + * 使用分层架构中的拦截器模式,将响应结果解包逻辑与异常捕获逻辑外抽至 errorMiddleware [cite: 270]。 + */ +client.interceptors.response.use(responseInterceptor, errorInterceptor); + + +/** + * 业务错误处理函数 (兼容性封装) + * 专门用于手动捕获并处理符合 BusinessError 协议的逻辑错误。 + * @function handleBusinessError + * @param {BusinessError} error - 封装了业务状态码及消息的错误对象 + */ +export const handleBusinessError = (error: BusinessError) => { + // 转发给全局错误处理器的 handle 逻辑,执行弹窗提示或重定向 + errorHandler.handleError(error); +}; + +/** + * 自动化请求重试高阶函数 + * 针对特定的临时性错误(如网络瞬断、服务器 5xx)采用指数退避算法进行重试。 + * 旨在提升系统在极端情况下的鲁棒性,满足核心服务可用性不低于 85% 的目标 [cite: 541]。 + * @async + * @template T + * @param {Function} apiCall - 封装了实际 API 调用的异步函数闭包 + * @param {number} [maxRetries=3] - 最大重试次数 + * @returns {Promise} 返回原始请求预期的响应数据 + */ +export const withRetry = async ( + apiCall: () => Promise, + maxRetries: number = 3 +): Promise => { + for (let i = 0; i < maxRetries; i++) { + try { + // 尝试执行原始 API 调用 + return await apiCall(); + } catch (error) { + /** + * 终止条件判定: + * 1. 已达到最大尝试次数限制; + * 2. 捕获的错误被判定为“不可重试”(如 4xx 业务逻辑错误)。 + */ + if (i === maxRetries - 1 || !isRetryableError(error)) { + throw error; + } + + /** + * 指数退避 (Exponential Backoff) 策略: + * 随着失败次数增加,延迟时间呈 2^n 指数增长,旨在减轻后端服务器瞬间并发压力。 + */ + await delay(Math.pow(2, i) * 1000); + } + } + throw new Error('Max retries exceeded'); +}; + +/** + * 错误可重试性评估算法 + * @function isRetryableError + * @private + * @param {any} error - 捕获的原始错误对象 + * @returns {boolean} 若错误具备恢复可能性则返回 true + */ +const isRetryableError = (error: any): boolean => { + // 业务逻辑错误(如参数校验失败、余额不足)代表逻辑终点,不应进行重试 + if (error instanceof BusinessError) { + return false; + } + + // 针对 HTTP 状态码进行判定 + if (error.response?.status) { + const status = error.response.status; + /** + * 判定准则: + * 1. 5xx 系列服务器内部错误; + * 2. 408 请求超时错误。 + */ + return status >= 500 || status === 408; + } + + // 无状态码的网络级异常(如断网、DNS 故障、连接超时)判定为可重试 + return error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT'; +}; + +/** + * 通用异步延迟实用函数 + * 基于 Promise 封装的 setTimeout,用于在异步流中实现非阻塞暂停。 + * @function delay + * @param {number} ms - 延迟时长(毫秒) + */ +const delay = (ms: number): Promise => { + return new Promise(resolve => setTimeout(resolve, ms)); +}; + +/** + * 执行全局错误处理器的初始化流程 + * 绑定全局事件监听器,准备接管未捕获的 Promise 异常。 + */ +errorHandler.initialize(); + +/** + * 模块导出定义 + * 提供单例 Axios 客户端及其关联的错误处理器。 + */ +export { errorHandler }; +export default client; \ No newline at end of file diff --git a/src/frontend/src/api/database.ts b/src/frontend/src/api/database.ts new file mode 100644 index 0000000..a6dc976 --- /dev/null +++ b/src/frontend/src/api/database.ts @@ -0,0 +1,144 @@ +/** + * @file database.ts + * @module API/Database-Inspector + * @description 数据库自省与元数据提取 API 服务模块。 + * 本模块是系统“技术无感”设计理念的核心支撑,负责从物理 MySQL/Postgres 实例中提取库表结构。 + * 提取的元数据用于驱动前端的“库表结构面板”、“动态 ER 图渲染引擎”以及“数据探查器”。 + * * 模块设计遵循以下原则: + * 1. 结构透明化:打破 AI 生成的“黑盒”,让非技术用户直观感知数据库状态 [cite: 669]; + * 2. 采样安全:默认采用 limit 约束,防止大数据量请求造成的瞬时链路阻塞。 + * * @author Wang Lirong (王利蓉) + * @version 2.1.0 + * @date 2026-01-02 + */ + +import client from './client'; + +/** + * 数据库表基础信息接口 + * @interface TableInfo + * @description 用于 Explorer 侧边栏展示的轻量级表描述对象。 + * @property {string} name - 数据库表名 + * @property {string} [type] - 表类型(如:BASE TABLE, VIEW 等) + */ +export interface TableInfo { + name: string; + type?: string; +} + +/** + * 字段/列元数据详细信息接口 + * @interface ColumnInfo + * @description 适配数据库 DESCRIBE 或 information_schema 输出的标准结构。 + * 这些元数据是 Schema Agent 验证逻辑范式 (3NF) 的重要参考 [cite: 341, 704]。 + * @property {string} field - 字段名称 + * @property {string} type - 数据类型(如:varchar(255), int 等) + * @property {string} null - 是否允许为空 (YES/NO) + * @property {string} key - 索引类型(PRI 为主键,UNI 为唯一键,MUL 为普通索引) + * @property {any} default - 默认值 + * @property {string} extra - 额外属性(如:auto_increment) + */ +export interface ColumnInfo { + field: string; + type: string; + null: string; + key: string; + default: any; + extra: string; +} + +// ========================================================= +// 第一部分:基于会话 (Session ID) 的 API 体系 +// 旨在支持“对话式管理”,确保查询上下文与特定分析话题绑定 [cite: 407, 706]。 +// ========================================================= + +/** + * 获取指定会话关联的数据库完整表结构列表 + * 常用于在对话窗口右侧实时渲染“库表可视化面板” [cite: 380, 411]。 + * @async + * @function fetchDatabaseStructure + * @param {number} sessionId - 当前活跃的分析会话 ID + * @returns {Promise} 返回包含所有业务逻辑表的数组 + */ +export const fetchDatabaseStructure = async (sessionId: number): Promise => { + // 执行 GET 请求,获取该会话上下文下的元数据快照 + return client.get(`/v1/database/${sessionId}/tables`); +}; + +/** + * 分页/限量获取指定表的实时行数据 + * 用于在前端以结构化表格形式呈现原始数据采样,满足 SF5 智能结果呈现需求 [cite: 212, 602]。 + * @async + * @function fetchTableData + * @param {number} sessionId - 会话识别码 + * @param {string} tableName - 目标查询表名 + * @returns {Promise} 返回 JSON 格式的行记录数组 + */ +export const fetchTableData = async (sessionId: number, tableName: string): Promise => { + // 默认限制返回 100 条记录,以平衡加载性能与用户探查需求 + return client.get(`/v1/database/${sessionId}/tables/${tableName}/data`, { + params: { limit: 100 } + }); +}; + +/** + * 获取指定表的详细 Schema 定义(列元数据) + * 该接口为“图形化 ER 关系图”组件提供数据支撑,展示字段含义及约束 [cite: 101, 682]。 + * @async + * @function fetchTableSchema + * @param {number} sessionId - 会话标识 + * @param {string} tableName - 待分析的表名 + * @returns {Promise} 字段详情定义列表 + */ +export const fetchTableSchema = async (sessionId: number, tableName: string): Promise => { + // 从后端元数据库或物理实例中实时检索列属性 + return client.get(`/v1/database/${sessionId}/tables/${tableName}/schema`); +}; + +// ========================================================= +// 第二部分:基于项目 (Project ID) 的 API 体系 +// 旨在支持“项目级隔离”与全局管理逻辑,无需依赖特定对话上下文 [cite: 104, 286]。 +// 适用于项目工作台 (Workspace) 的初始化加载流程。 +// ========================================================= + +/** + * 通过项目 ID 获取物理数据库的所有表清单 + * 实现项目工作台 (Workspace) 左侧 Explorer 树状菜单的动态构建 [cite: 247]。 + * @async + * @function fetchDatabaseStructureByProject + * @param {number} projectId - 物理项目 ID + * @returns {Promise} + */ +export const fetchDatabaseStructureByProject = async (projectId: number): Promise => { + // 调用项目级元数据端点,该操作不涉及会话上下文记忆 + return client.get(`/v1/database/project/${projectId}/tables`); +}; + +/** + * 通过项目 ID 探查特定表的数据样本 + * 用于非会话模式下的通用数据管理与内容核对逻辑。 + * @async + * @function fetchTableDataByProject + * @param {number} projectId - 项目唯一标识 + * @param {string} tableName - 物理表名 + */ +export const fetchTableDataByProject = async (projectId: number, tableName: string): Promise => { + // 执行带参数的数据采样请求 + return client.get(`/v1/database/project/${projectId}/tables/${tableName}/data`, { + params: { limit: 100 } + }); +}; + +/** + * 通过项目 ID 调取表的 Schema 详细架构信息 + * 用于构建项目级的“数据字典”或静态 ER 图展示。 + * @async + * @function fetchTableSchemaByProject + * @param {number} projectId - 项目 ID + * @param {string} tableName - 表名 + * @returns {Promise} + */ +export const fetchTableSchemaByProject = async (projectId: number, tableName: string): Promise => { + // 发起对特定项目下表结构的深度检索 + return client.get(`/v1/database/project/${projectId}/tables/${tableName}/schema`); +}; \ No newline at end of file diff --git a/src/frontend/src/api/errorMiddleware.ts b/src/frontend/src/api/errorMiddleware.ts new file mode 100644 index 0000000..4a69148 --- /dev/null +++ b/src/frontend/src/api/errorMiddleware.ts @@ -0,0 +1,381 @@ +/** + * @file errorMiddleware.ts + * @module Middleware/API-Error + * @description 统一 API 错误处理与响应拦截中间件。 + * 本模块作为前端与后端 RESTful API 交互的逻辑哨兵,主要职能包括: + * 1. 响应解包与统一格式校验:处理后端定义的 UnifiedResponse 结构; + * 2. 业务状态码 (BusinessCode) 转换:将后端逻辑异常映射为前端 BusinessError [cite: 543]; + * 3. 异步任务容错包装:提供多种高级包装器(如 Fail-Fast, Batch-Handling)以提升系统可靠性; + * 4. 实时错误监控与审计:结合 ErrorMonitor 类实现前端运行时的异常轨迹记录。 + * @author Wang Lirong (王利蓉) + * @version 2.2.0 + * @date 2026-01-02 + */ + +import { AxiosError, AxiosResponse } from 'axios'; +import { BusinessError, BusinessCode, UnifiedResponse } from '../types'; +import { globalErrorHandler } from '../utils/errorHandling'; + +/** + * API 错误处理中间件逻辑定义 + * 对应项目需求规格说明书中的全局稳定性保障条款: + * 7.1 系统一致性 | 7.2 异常捕获策略 | 7.3 用户友好提示 | 7.4 监控审计集成 [cite: 187, 542] + */ + +/** + * 响应拦截器 (Response Interceptor) + * 核心职能:对 Axios 返回的原始响应进行预处理,提取业务载荷并处理潜在的逻辑错误。 + * 该拦截器确保了业务代码可以直接接收到 data 字段中的有效信息,而无需重复判断响应状态。 + * @function responseInterceptor + * @param {AxiosResponse} response - Axios 原始响应对象 + * @throws {BusinessError} 当后端返回的业务 code 不为 SUCCESS 时抛出 + * @returns {any} 剥离包装后的纯业务数据 + */ +export const responseInterceptor = (response: AxiosResponse) => { + // 获取后端统一返回格式的数据载荷 + const data = response.data as UnifiedResponse; + + // 步骤 1:检查返回数据是否符合系统预设的 UnifiedResponse 协议 + // 必须同时包含 code, message 和 data 三个核心字段 [cite: 183] + if (data && typeof data === 'object' && 'code' in data && 'message' in data && 'data' in data) { + + // 步骤 2:验证业务状态码 + // 若 code 不等于 200 (Success),则判定为业务逻辑层面的异常 + if (data.code !== BusinessCode.SUCCESS) { + // 创建业务异常实例,并立即交由全局处理器进行 UI 弹窗提示 + const businessError = new BusinessError(data.code, data.message); + globalErrorHandler.handleError(businessError); + + // 阻断 Promise 链,将控制权交给调用方的 catch 块 + throw businessError; + } + + // 步骤 3:业务逻辑正常,直接透传有效数据载荷 + return data.data; + } + + // 向后兼容处理:若返回数据不符合 UnifiedResponse 格式,则原样返回以防止系统解析崩溃 + return response.data; +}; + +/** + * 错误拦截器 (Error Interceptor) + * 核心职能:统一拦截所有非 2xx 状态码的 HTTP 错误及由于网络断开导致的物理连接错误。 + * @function errorInterceptor + * @param {AxiosError} error - Axios 捕获的原始错误实例 + * @returns {Promise} + */ +export const errorInterceptor = (error: AxiosError) => { + // 判定分支:如果是 BusinessError,说明拦截器 responseInterceptor 已先行处理 + // 此处仅执行 Promise 的拒绝操作,避免二次弹窗提示 + if (error instanceof BusinessError) { + return Promise.reject(error); + } + + // 针对物理网络故障、DNS 失败或 HTTP 状态码错误(如 401, 500 等)调用全局处理器 [cite: 540-541] + globalErrorHandler.handleError(error); + return Promise.reject(error); +}; + +/** + * API 调用高阶包装器 + * 核心职能:为单次异步 API 调用提供标准化的 Loading 状态管理和错误拦截逻辑。 + * 旨在简化页面组件代码,实现声明式的异常处理。 + * @async + * @function withErrorHandling + * @template T + * @param {Function} apiCall - 待执行的异步业务请求闭包 + * @param {Object} [options] - 配置参数 + * @param {boolean} [options.showLoading=false] - 是否自动触发全局 Loading 蒙层 + * @param {Function} [options.customErrorHandler] - 可选的自定义错误处理逻辑,若提供则跳过全局处理 + * @param {boolean} [options.suppressGlobalError=false] - 是否静默处理错误(不显示提示框) + * @returns {Promise} + */ +export const withErrorHandling = async ( + apiCall: () => Promise, + options: { + showLoading?: boolean; + customErrorHandler?: (error: any) => void; + suppressGlobalError?: boolean; + } = {} +): Promise => { + const { showLoading = false, customErrorHandler, suppressGlobalError = false } = options; + + try { + // 处理请求前的 UI 反馈逻辑 + if (showLoading) { + // 执行 Loading 开启逻辑(如调用 store.dispatch('showLoading')) + } + + // 执行核心异步业务逻辑 + const result = await apiCall(); + + // 请求成功,关闭 UI 反馈 + if (showLoading) { + // 执行 Loading 隐藏逻辑 + } + + return result; + } catch (error) { + // 异常发生,确保 UI 反馈被正确回收 + if (showLoading) { + // 执行 Loading 隐藏逻辑 + } + + // 步骤评估:判定采用哪种错误反馈策略 + if (customErrorHandler) { + // 策略 A:采用业务方提供的特定错误处理器 + customErrorHandler(error); + } else if (!suppressGlobalError) { + // 策略 B:默认策略,交由全局统一处理模块进行用户反馈(SF10 账户提示等) [cite: 463] + globalErrorHandler.handleError(error); + } + + // 重新抛出,允许调用方感知失败并执行后续清理 + throw error; + } +}; + +/** + * 批量 API 调用错误管理策略 + * 核心职能:针对并发执行的多个 API 请求,提供“快速失败”或“全量收集”两种控制流模式。 + * 适用于如批量创建数据库表 (SF2) 或同步更新多项业务术语 (SF7) 的场景 [cite: 436, 444]。 + * @async + * @function withBatchErrorHandling + * @template T + * @param {Array} apiCalls - 异步请求闭包数组 + * @param {Object} [options] + * @param {boolean} [options.failFast=false] - 快速失败模式:任一请求失败则终止全体流程 + * @param {boolean} [options.collectErrors=true] - 是否自动上报各子请求的错误 + */ +export const withBatchErrorHandling = async ( + apiCalls: Array<() => Promise>, + options: { + failFast?: boolean; + collectErrors?: boolean; + } = {} +): Promise<{ + results: Array; + errors: Array; + hasErrors: boolean; +}> => { + const { failFast = false, collectErrors = true } = options; + const results: Array = []; + const errors: Array = []; + + // 分支逻辑 1:快速失败模式 (Fail-Fast Strategy) + if (failFast) { + try { + // 使用 Promise.all 触发并发,利用其原生短路机制 + const allResults = await Promise.all(apiCalls.map(call => call())); + return { + results: allResults, + errors: new Array(allResults.length).fill(null), + hasErrors: false, + }; + } catch (error) { + // 捕获到首个错误即停止,并上报全局 + globalErrorHandler.handleError(error); + throw error; + } + } else { + // 分支逻辑 2:全量收集模式 (Collect-All Strategy) + // 确保即使部分任务失败,其他子任务也能继续执行并记录结果 + const promises = apiCalls.map(async (call, index) => { + try { + const result = await call(); + results[index] = result; + errors[index] = null; + } catch (error) { + results[index] = null; + errors[index] = error as Error; + + // 若开启监控,则将每一个独立错误同步至全局处理器 + if (collectErrors) { + globalErrorHandler.handleError(error); + } + } + }); + + // 等待所有并行任务(无论成功失败)最终结算 + await Promise.all(promises); + + return { + results, + errors, + hasErrors: errors.some(error => error !== null), + }; + } +}; + +/** + * 条件式错误处理包装器 + * 核心职能:仅在满足特定断言条件时触发错误提示逻辑。 + * @async + * @function withConditionalErrorHandling + */ +export const withConditionalErrorHandling = async ( + apiCall: () => Promise, + condition: (error: any) => boolean, + customHandler?: (error: any) => void +): Promise => { + try { + return await apiCall(); + } catch (error) { + // 步骤:评估错误是否符合预期的处理条件 + if (condition(error)) { + if (customHandler) { + customHandler(error); + } else { + globalErrorHandler.handleError(error); + } + } + throw error; + } +}; + +/** + * 错误恢复与容错包装器 (Resilience Wrapper) + * 核心职能:在 API 调用失败时,通过预设的默认值或补偿逻辑维持业务连续性。 + * 符合可靠性需求中的系统稳定性目标 [cite: 541]。 + * @async + * @function withErrorRecovery + */ +export const withErrorRecovery = async ( + apiCall: () => Promise, + recovery: { + defaultValue?: T; + recoveryFn?: (error: any) => T | Promise; + shouldRecover?: (error: any) => boolean; + } +): Promise => { + const { defaultValue, recoveryFn, shouldRecover } = recovery; + + try { + return await apiCall(); + } catch (error) { + // 判定逻辑:当前错误场景是否允许执行恢复 + const shouldAttemptRecovery = shouldRecover ? shouldRecover(error) : true; + + if (shouldAttemptRecovery) { + // 策略 A:执行特定的补偿函数 + if (recoveryFn) { + try { + return await recoveryFn(error); + } catch (recoveryError) { + globalErrorHandler.handleError(recoveryError); + throw recoveryError; + } + } else if (defaultValue !== undefined) { + // 策略 B:回退至预设的静态默认值 + return defaultValue; + } + } + + // 若不符合恢复条件或无恢复策略,则正常抛出异常并上报 + globalErrorHandler.handleError(error); + throw error; + } +}; + +/** + * 运行时错误监控类 (Error Monitor) + * 核心职能:实现前端运行时的异常数据记录与审计。 + * 为管理员提供“系统监控与分析” (SF11) 的原始数据支撑 [cite: 107, 218]。 + */ +export class ErrorMonitor { + // 错误类型统计字典 + private errorCounts: Map = new Map(); + // 最近错误历史队列 + private errorHistory: Array<{ + timestamp: number; + error: any; + context?: string; + }> = []; + + /** + * 记录并归档当前捕获的错误 + * @method recordError + * @param {any} error - 捕获的异常实例 + * @param {string} [context] - 错误发生的业务上下文描述 + */ + recordError(error: any, context?: string): void { + const errorKey = this.getErrorKey(error); + const currentCount = this.errorCounts.get(errorKey) || 0; + this.errorCounts.set(errorKey, currentCount + 1); + + // 记录详细的时间戳及上下文信息,便于故障回溯 [cite: 542] + this.errorHistory.push({ + timestamp: Date.now(), + error, + context, + }); + + // 步骤:维持固定窗口大小的本地历史记录,防止内存溢出 + if (this.errorHistory.length > 1000) { + this.errorHistory = this.errorHistory.slice(-500); + } + } + + /** + * 获取当前系统的实时错误统计报告 + * 常用于管理员仪表盘 (Dashboard) 的数据渲染 [cite: 253, 742] + */ + getErrorStats(): { + totalErrors: number; + errorsByType: Record; + recentErrors: Array; + } { + const totalErrors = Array.from(this.errorCounts.values()).reduce((sum, count) => sum + count, 0); + const errorsByType = Object.fromEntries(this.errorCounts); + const recentErrors = this.errorHistory.slice(-10); + + return { + totalErrors, + errorsByType, + recentErrors, + }; + } + + /** + * 重置监控统计数据 + * @method clearStats + */ + clearStats(): void { + this.errorCounts.clear(); + this.errorHistory = []; + } + + /** + * 内部方法:生成错误的唯一标识键 + * 区分业务错误、HTTP 错误及网络级故障 + * @private + */ + private getErrorKey(error: any): string { + if (error instanceof BusinessError) { + return `BusinessError_${error.code}`; + } + if (error.response?.status) { + return `HttpError_${error.response.status}`; + } + if (error.code) { + return `NetworkError_${error.code}`; + } + return 'UnknownError'; + } +} + +// 创建单例模式的全局错误监控实例 +export const errorMonitor = new ErrorMonitor(); + +/** + * 装饰器/拦截模式:扩展全局错误处理器 + * 确保每一次通过 globalErrorHandler 的调用都会同步被监控器记录 + */ +const originalHandleError = globalErrorHandler.handleError.bind(globalErrorHandler); +globalErrorHandler.handleError = (error: any) => { + // 1. 同步执行审计记录逻辑 + errorMonitor.recordError(error); + // 2. 执行原始的用户提示/反馈逻辑 + originalHandleError(error); +}; \ No newline at end of file diff --git a/src/frontend/src/api/glossary.ts b/src/frontend/src/api/glossary.ts new file mode 100644 index 0000000..605a316 --- /dev/null +++ b/src/frontend/src/api/glossary.ts @@ -0,0 +1,144 @@ +/** + * @file glossary.ts + * @module API/Glossary-Knowledge-Base + * @description 业务术语库(知识库)管理 API 模块。 + * 本模块对应系统核心特性 SF7(领域知识增强),旨在通过建立业务术语与技术字段的映射关系, + * 为多智能体框架提供行业上下文支持。 + * 核心功能: + * 1. 术语的 CRUD 管理; + * 2. 批量审计与清理; + * 3. 基于 Multipart/form-data 的外部知识导入。 + * @author Wang Lirong (王利蓉) + * @version 1.2.0 + * @date 2026-01-02 + */ + +import client from './client.ts'; +import { + KnowledgeTerm, + KnowledgeListResponse, + KnowledgeImportResponse +} from '../types.ts'; + +/** + * 创建业务术语的请求参数接口 + * @interface CreateTermParams + * @description 封装新术语录入时的核心元数据 + * @property {string} term - 业务术语名称(如:“日活”、“订单状态”) + * @property {string} definition - 术语的精确业务定义,用于 LLM 的 Prompt 增强 + * @property {string} [examples] - 可选:提供该术语在实际场景中的应用示例或枚举值 + */ +export interface CreateTermParams { + term: string; + definition: string; + examples?: string; +} + +/** + * 更新业务术语的请求参数接口 + * @interface UpdateTermParams + * @description 支持对现有术语进行增量更新 + */ +export interface UpdateTermParams { + term?: string; + definition?: string; + examples?: string; +} + +/** + * 业务术语 API 核心调用对象 + * 为“知识增强”面板提供数据交互支撑 + */ +export const glossaryApi = { + /** + * 2. 分页获取当前项目的术语列表 + * 允许用户在工作台右侧面板中检索已录入的知识条目。 + * 支持基于关键词的模糊搜索,提升大规模术语库下的定位效率。 + * @method getList + * @param {string} projectId - 关联的项目唯一识别码 + * @param {number} [page=1] - 当前请求页码 + * @param {number} [pageSize=20] - 每页展示的数据量 + * @param {string} [search] - 搜索关键词(匹配术语名或定义) + * @returns {Promise} 包含分页元数据与术语数组的 Promise + */ + getList: (projectId: string, page: number = 1, pageSize: number = 20, search?: string) => { + // 构造带有分页和搜索过滤器的 GET 请求 + return client.get(`/v1/projects/${projectId}/knowledge`, { + params: { + page, + page_size: pageSize, + search + } + }); + }, + + /** + * 1. 在指定项目下创建新的业务术语 + * 录入后的术语将立即进入 Agent 的感知范围,辅助后续的 SQL 生成。 + * @method create + * @param {string} projectId - 项目 ID + * @param {CreateTermParams} data - 术语定义数据载荷 + * @returns {Promise} 返回新创建的术语对象(含服务端生成的 ID) + */ + create: (projectId: string, data: CreateTermParams) => { + // 发起 POST 请求执行知识入库 + return client.post(`/v1/projects/${projectId}/knowledge`, data); + }, + + /** + * 3. 更新现有术语的详细信息 + * 允许用户修正过时的业务定义或补充应用示例。 + * @method update + * @param {string} projectId - 项目 ID + * @param {number} knowledgeId - 待修改术语的自增 ID + * @param {UpdateTermParams} data - 包含待修改字段的对象 + */ + update: (projectId: string, knowledgeId: number, data: UpdateTermParams) => { + // 使用 PATCH 方法进行局部字段更新,符合 RESTful 设计范式 + return client.patch(`/v1/projects/${projectId}/knowledge/${knowledgeId}`, data); + }, + + /** + * 4. 批量删除选定的业务术语 + * 支持在管理面板中进行多选后的统一清理操作。 + * @method deleteBatch + * @param {string} projectId - 项目 ID + * @param {number[]} ids - 待删除的术语 ID 数组 + * @returns {Promise} + */ + deleteBatch: (projectId: string, ids: number[]) => { + /** + * 注意:根据 Axios 规范,DELETE 请求若需携带请求体 (Body), + * 必须显式放置在配置对象的 data 属性中。 + */ + return client.delete(`/v1/projects/${projectId}/knowledge/batch`, { + data: { ids } + }); + }, + + /** + * 5. 导入术语库文件 (支持 Excel/CSV) + * 对应项目需求中的“批量导入”功能。通过上传标准化模板, + * 快速构建特定行业的知识图谱初步原型。 + * @method importTerms + * @param {string} projectId - 项目 ID + * @param {File} file - 待上传的二进制文件对象 + */ + importTerms: (projectId: string, file: File) => { + // 初始化 FormData 对象以支持 RFC 7578 标准的多部分表单上传 + const formData = new FormData(); + // 注入文件流,后端将通过 'file' 键名进行解析 + formData.append('file', file); + + /** + * 发起文件上传请求。 + * 必须显式声明 Content-Type 为 multipart/form-data, + * 确保底层传输协议能够正确处理二进制数据流。 + */ + return client.post(`/v1/projects/${projectId}/knowledge/import`, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + } +}; \ No newline at end of file diff --git a/src/frontend/src/api/project.ts b/src/frontend/src/api/project.ts new file mode 100644 index 0000000..99d0c7d --- /dev/null +++ b/src/frontend/src/api/project.ts @@ -0,0 +1,220 @@ +/** + * @file project.ts + * @module API/Project-Lifecycle-Management + * @description 项目生命周期与 AI 协同核心 API 模块。 + * 本模块是“基于大模型多智能体框架的数据库自动部署与库表生成系统”的业务引擎核心。 + * 负责处理从项目初始化、自然语言需求分析、Schema 逻辑建模、DDL 语句生成到物理部署的全链路流程。 + * * 核心特性支撑: + * 1. 异步生成流:对接后端基于 Celery/FastAPI 的异步任务队列,支持进度实时回显(SF2); + * 2. 智能可视化:触发动态 ER 图渲染逻辑(SF5); + * 3. 安全删除锁:实施基于二次令牌验证的资源销毁保护策略。 + * * @author Wang Lirong (王利蓉) + * @version 3.0.0 (适配迭代开发计划第三稿) + * @date 2026-01-02 + */ + +import client from './client'; +import { ProjectDTO, PageData } from '../types'; + +/** * 透传项目数据传输对象类型定义 + */ +export type { ProjectDTO }; + +/** + * 创建新项目的请求参数接口 + * @interface CreateProjectParams + * @description 封装项目初始化的元数据。 + * @property {string} name - 项目展示名称,用于在 Dashboard 识别不同业务模块。 + * @property {string} type - 目标数据库类型。新增支持 SQLite 以满足轻量级本地化实验需求。 + * @property {string} description - 业务背景描述,作为 Schema Agent 进行需求提取的原始语料。 + */ +export interface CreateProjectParams { + name: string; + type: 'MySQL' | 'PostgreSQL' | 'SQLite'; + description: string; +} + +/** + * 更新项目信息的请求参数接口 (PATCH) + * @interface UpdateProjectParams + * @description 对应需求微调场景,允许用户在不销毁项目的前提下修改业务逻辑倾向。 + * @property {string} [project_name] - 可选的项目重命名。 + * @property {string} [description] - 增量更新的业务描述。 + * @property {Record} [schema_definition] - AI 生成的逻辑 Schema JSON 对象。 + */ +export interface UpdateProjectParams { + project_name?: string; + description?: string; + schema_definition?: Record; +} + +/** + * 确认删除操作的响应结构 + * @interface ConfirmDeleteResponse + * @description 实现双重确认机制。后端生成临时令牌以防止误操作导致的数据丢失。 + * @property {string} confirmation_token - 具有时效性的二次验证令牌。 + * @property {string} expires_at - 令牌过期的时间戳(ISO 8601)。 + */ +export interface ConfirmDeleteResponse { + confirmation_token: string; + expires_at: string; +} + +/** + * 项目创建初始响应 + * @interface CreateProjectResponse + */ +export interface CreateProjectResponse { + project_id: number; + message: string; +} + +// ========================================================= +// 项目管理核心服务集 +// ========================================================= + +/** + * 1. 获取当前用户所属的所有项目列表 + * 实现 Workspace 主页的项目卡片网格渲染。 + * 采用 PageData 包装器以适配后端的统一分页协议。 + * @async + * @function fetchProjects + * @returns {Promise>} 包含项目简明信息的列表 + */ +export const fetchProjects = async (): Promise> => { + // 响应拦截器会自动从 UnifiedResponse> 中提取 data 部分 + return await client.get('/v1/projects/'); +}; + +/** + * 2. 初始化创建新数据库项目 + * 该操作会触发后端的项目命名空间分配及元数据入库流程。 + * @async + * @function createProject + * @param {CreateProjectParams} params - 项目创建所需的配置参数 + * @returns {Promise} 包含新生成的项目 ID + */ +export const createProject = async (params: CreateProjectParams): Promise => { + // 适配接口文档:POST /api/v1/projects/ + return await client.post('/v1/projects/', { + project_name: params.name, + db_type: params.type.toLowerCase(), // 严格遵循后端数据库标识规范(全小写) + description: params.description + }); +}; + +/** + * 3. 获取特定项目的详尽档案信息 + * 返回数据涵盖生成进度、逻辑 Schema 定义、当前 DDL 语句及数据库连接参数。 + * 常用于工作台 (Workspace) 的状态恢复与初始化加载。 + * @async + * @function getProjectDetail + * @param {string | number} projectId - 目标项目唯一标识 + * @returns {Promise} + */ +export const getProjectDetail = async (projectId: string | number): Promise => { + // 后端返回 UnifiedResponse,此处直接解包为业务对象 + return await client.get(`/v1/projects/${projectId}`); +}; + +/** + * 3.1 触发 DDL 语句自动化生成流程 + * 核心逻辑:用户确认逻辑 Schema 后,调用 DDL Agent 基于目标数据库方言(Dialect)生成部署脚本。 + * 该操作通常为异步任务,前端需监听进度条状态。 + * @async + * @function generateDDL + * @param {string | number} projectId - 项目 ID + * @param {string} confirmedSchema - 用户核对并微调后的 JSON Schema 文本 + * @param {string} [requirements] - 额外的约束需求(如:必须包含审计字段、使用 InnoDB 等) + */ +export const generateDDL = async (projectId: string | number, confirmedSchema: string, requirements?: string): Promise => { + // 调用后端异步任务端点,启动多智能体协作流 + await client.post(`/v1/projects/${projectId}/generate-ddl`, { + confirmed_schema: confirmedSchema, + requirements + }); +}; + +/** + * 3.2 执行物理数据库部署 + * 这是“技术无感”流程的最后一步。后端会在独立 MySQL/PostgreSQL 实例中执行 DDL 语句。 + * 实现 SF1 自动化部署特性。 + * @async + * @function deployProject + * @param {string | number} projectId - 项目 ID + * @param {string} confirmedDDL - 最终核准的 SQL 语句全集 + * @param {string} [confirmedSchema] - 同步更新的逻辑架构定义 + * @param {boolean} [useSmartParse=false] - 是否启用智能解析以优化字段映射 + * @returns {Promise} 返回部署完成后的最新项目状态 + */ +export const deployProject = async (projectId: string | number, confirmedDDL: string, confirmedSchema?: string, useSmartParse: boolean = false): Promise => { + return await client.post(`/v1/projects/${projectId}/deploy`, { + confirmed_ddl: confirmedDDL, + confirmed_schema: confirmedSchema, + use_smart_parse: useSmartParse + }); +}; + +/** + * 3.3 异步重新生成 ER 关系图数据 + * 基于当前的 Schema 定义,通过可视化智能体生成符合 Mermaid 或 Graphviz 规范的渲染描述。 + * 对应 SF5 可视化增强特性 [cite: 85-87]。 + * @async + * @function regenerateER + * @param {string | number} projectId - 项目 ID + * @param {string} schemaText - 用于生成图形的 Schema 原始定义 + * @param {string} [aiModel='gpt4'] - 指定绘图辅助大模型,默认为 GPT-4o 以获得最佳逻辑关联性 + * @returns {Promise<{ task_id: string; message: string }>} 返回异步任务 ID 用于轮询状态 + */ +export const regenerateER = async (projectId: string | number, schemaText: string, aiModel: string = 'gpt4'): Promise<{ task_id: string; message: string }> => { + return await client.post(`/v1/projects/${projectId}/regenerate-er`, { + schema_text: schemaText, + ai_model: aiModel + }); +}; + +/** + * 4. 动态更新项目核心元数据 + * 用于在项目进行过程中,手动触发需求微调或修正 AI 生成的错误定义。 + * 修改后通常需要重新调用生成流程以保证数据一致性。 + * @async + * @function updateProject + */ +export const updateProject = async (projectId: string | number, params: UpdateProjectParams): Promise => { + // 使用局部更新动词 PATCH + return await client.patch(`/v1/projects/${projectId}`, params); +}; + +/** + * 5. 安全审计:发起删除确认请求 + * 对应 Usability-2 与 Security-3 约束:针对危险操作执行双重确认提示 。 + * 管理员或用户需在前端输入项目名称作为确认文本。 + * @async + * @function confirmDeleteProject + * @param {string | number} projectId - 待删除项目 ID + * @param {string} confirmationText - 用户输入的确认标识(通常为项目名) + * @returns {Promise} 包含下一步操作所需的临时 Token + */ +export const confirmDeleteProject = async (projectId: string | number, confirmationText: string): Promise => { + return await client.post(`/v1/projects/${projectId}/confirm-delete`, { + confirmation_text: confirmationText + }); +}; + +/** + * 6. 执行物理资源的最终删除 + * 此操作不可逆。需要携带上一步获取的授权 Token 方可执行。 + * 实现 Security-1 项目级物理隔离的闭环管理。 + * @async + * @function deleteProject + * @param {string | number} projectId - 项目 ID + * @param {string} token - 由 confirmDeleteProject 签发的限时确认令牌 + */ +export const deleteProject = async (projectId: string | number, token: string): Promise => { + // 将授权令牌注入自定义 Header 字段 X-Confirmation-Token + await client.delete(`/v1/projects/${projectId}`, { + headers: { + 'X-Confirmation-Token': token + } + }); +}; \ No newline at end of file diff --git a/src/frontend/src/api/reports.ts b/src/frontend/src/api/reports.ts new file mode 100644 index 0000000..db0e205 --- /dev/null +++ b/src/frontend/src/api/reports.ts @@ -0,0 +1,145 @@ +/** + * @file reports.ts + * @module API/Report-Visualization + * @description 报表与可视化分析 API 服务模块。 + * 本模块支撑系统核心特性 SF5(结果可视化),负责处理从分析查询到图形化报表的转化逻辑。 + * 核心流程: + * 1. 溯源追踪:从历史查询记录中提取结果集元数据; + * 2. 维度映射:配置 X/Y 轴业务指标及图表类型(柱状图、折线图等); + * 3. 持久化存储:将报表配置保存至项目仪表盘,实现分析结果的资产化管理。 + * @author Wang Lirong (王利蓉) + * @version 1.4.0 + * @date 2026-01-02 + */ + +import client from './client'; +import { Report, QueryResult } from '../types'; + +/** + * 创建报表的请求参数接口 + * @interface CreateReportParams + * @description 封装报表生成的配置元数据。 + * 注意:后端将通过 source_query_id 自动关联历史快照数据,前端无需传输冗余的结果集明细。 + * @property {string} project_id - 归属的项目 ID,确保报表在项目间物理隔离。 + * @property {string} name - 报表展示名称。 + * @property {string} type - 图表样式类型(如 'bar', 'line', 'pie' 等)。 + * @property {string} [description] - 对该可视化分析的业务背景描述。 + * @property {Object} chart_config - 图表维度配置。 + * @property {string} chart_config.x_axis_key - 映射至横轴的字段键名。 + * @property {string} chart_config.y_axis_key - 映射至纵轴的数值字段键名。 + * @property {string} source_query_id - 对应查询历史记录的唯一 ID。 + */ +export interface CreateReportParams { + project_id: string; + name: string; + type: string; + description?: string; + chart_config: { + x_axis_key: string; + y_axis_key: string; + }; + source_query_id: string; +} + +/** + * 历史查询结果中的字段元数据定义 + * @interface HistoryQueryField + */ +export interface HistoryQueryField { + name: string; + type: 'string' | 'number' | 'date' | 'bool' | 'object'; +} + +/** + * 携带字段类型信息的增强版查询结果集 + * @interface HistoryQueryResult + * @extends QueryResult + */ +export interface HistoryQueryResult extends QueryResult { + fields: HistoryQueryField[]; +} + +/** + * 历史查询记录实体定义 + * @interface HistoryQuery + * @description 用于在报表创建向导中供用户回溯和选择数据源。 + * @property {string} id - 查询记录唯一标识。 + * @property {string} project_id - 项目关联 ID。 + * @property {string} query_text - 原始 SQL 或自然语言查询文本。 + * @property {string} timestamp - 执行时间戳。 + * @property {HistoryQueryResult} result - 包含数据及字段结构的快照。 + * @property {boolean} [reportable] - 标识该查询结果是否满足可视化条件(如:必须包含数值列)。 + * @property {string | null} [unreportable_reason] - 若不可生成报表,提供业务逻辑说明。 + */ +export interface HistoryQuery { + id: string; + project_id: string; + query_text: string; + timestamp: string; + result: HistoryQueryResult; + reportable?: boolean; + unreportable_reason?: string | null; +} + +/** + * 报表管理 API 核心调用对象 + * 驱动“报表主界面”与“报表制作向导”的数据交互 + */ +export const reportApi = { + /** + * 获取指定项目下的所有已保存报表列表 + * 用于渲染项目工作台中的“分析报表”选项卡及仪表盘卡片。 + * @method getReports + * @param {string} project_id - 项目 ID + * @returns {Promise} 报表对象数组 + */ + getReports: (project_id: string) => { + // 发起 GET 请求,携带项目过滤参数执行检索 + return client.get('/v1/reports', { + params: { project_id } + }); + }, + + /** + * 基于选定的历史查询创建新报表 + * 实现从对话式分析到结构化可视化的跃迁。 + * @method createReport + * @param {CreateReportParams} data - 包含报表名称、图表类型及轴配置的载荷 + * @returns {Promise} 返回新生成的报表实体 + */ + createReport: (data: CreateReportParams) => { + // 执行 POST 请求,将前端字段名映射为后端预期的 API 字段名(如 report_name, chart_type) + return client.post(`/v1/projects/${data.project_id}/reports`, { + report_name: data.name, + query_id: Number(data.source_query_id), + chart_type: data.type, + description: data.description, + chart_config: data.chart_config + }); + }, + + /** + * 从项目中移除指定的报表配置 + * 执行该操作仅删除可视化层级的配置,不会影响底层的查询历史或原始数据。 + * @method deleteReport + * @param {string} reportId - 待删除报表的唯一 ID + */ + deleteReport: (reportId: string) => { + // 调用 DELETE 动词执行资源注销逻辑 + return client.delete(`/v1/reports/${reportId}`); + }, + + /** + * 调取当前项目的全量历史查询记录 + * 在“报表制作向导”的第一步中调用,允许用户基于之前的对话分析结果选择数据源。 + * @method getHistoryQueries + * @param {string} project_id - 目标项目 ID + * @returns {Promise} + */ + getHistoryQueries: (project_id: string) => { + // 后端执行历史记录过滤,确保用户仅能访问其权限范围内的查询轨迹 + return client.get('/v1/history-queries', { + params: { project_id } + }); + } +}; \ No newline at end of file diff --git a/src/frontend/src/api/session.ts b/src/frontend/src/api/session.ts new file mode 100644 index 0000000..b946ecf --- /dev/null +++ b/src/frontend/src/api/session.ts @@ -0,0 +1,182 @@ +/** + * @file session.ts + * @module API/Session-Interaction + * @description 交互会话与多智能体对话管理 API 模块。 + * 本模块是系统的交互中枢,负责管理用户与底层 AI 代理集群(Schema Agent, DDL Agent 等)之间的通信会话。 + * * 核心功能链: + * 1. 会话生命周期:支持会话的持久化存储、重命名及逻辑删除; + * 2. 状态化对话:基于 Session ID 维护多轮对话上下文,实现需求的逐步细化(SF6); + * 3. 安全指令确认:实现“人工介入循环 (Human-in-the-loop)”,针对高危 DML 操作执行安全审计与确认(SF3); + * 4. 异构模型调度:支持根据业务场景动态切换底层推理模型。 + * * @author Wang Lirong (王利蓉) + * @version 2.2.0 (适配迭代开发计划 β 版本) + * @date 2026-01-02 + */ + +import client from './client.ts'; +import { ChatResponse, PageData, AIModelOptionsResponse } from '../types'; + +/** + * 会话基础信息实体接口 + * @interface SessionItem + * @description 对应后端 SessionResponse 架构,记录对话环境的元数据。 + * @property {number} session_id - 会话唯一标识,用于挂载历史消息快照。 + * @property {string} session_name - 用户自定义的会话标题,默认为“新会话”。 + * @property {number} project_id - 所属项目的关联 ID,实现项目级资源隔离。 + * @property {string | null} current_model - 当前会话绑定的 AI 推理模型标识。 + * @property {string} created_at - 会话创建时间(ISO 8601)。 + * @property {string | null} last_activity - 最后一次交互时间,用于会话活跃度分析。 + */ +export interface SessionItem { + session_id: number; + session_name: string; + project_id: number; + current_model: string | null; + created_at: string; + last_activity: string | null; +} + +/** + * 会话管理与消息交互 API 核心调用对象 + * 支撑前端“对话面板”与“历史列表”的实时数据同步 + */ +export const sessionApi = { + /** + * 1.1 分页获取指定项目下的会话列表 + * 用于在工作台左侧导航栏渲染历史对话记录。 + * @method getList + * @param {number | string} projectId - 归属项目 ID + * @param {number} [page=0] - 分页起始索引 + * @param {number} [limit=100] - 单页获取上限,默认 100 以覆盖长列表需求 + * @returns {Promise>} 包含会话数组及总数的分页对象 + */ + getList: (projectId: number | string, page: number = 0, limit: number = 100) => { + // 执行 GET 请求,并将页码转换为后端的 skip/limit 偏移量模型 + return client.get>('/v1/sessions/', { + params: { + project_id: Number(projectId), + skip: page * limit, + limit + } + }); + }, + + /** + * 1.2 在当前项目下发起新的对话会话 + * 初始化一个新的对话环境,系统将为其分配独立的 Agent 上下文空间。 + * @method create + * @param {number | string} projectId - 项目 ID + * @param {string} [name] - 可选的会话名称 + */ + create: (projectId: number | string, name?: string) => { + // 向服务端提交创建申请,默认名称为“新会话” + return client.post('/v1/sessions/', { + project_id: Number(projectId), + session_name: name || '新会话' + }); + }, + + /** + * 1.3 获取特定会话的详尽元数据 + * 常用于在页面刷新后恢复特定会话的配置状态。 + * @method getDetail + * @param {number} sessionId - 会话识别码 + */ + getDetail: (sessionId: number) => { + return client.get(`/v1/sessions/${sessionId}`); + }, + + /** + * 1.4 对现有会话进行重命名或属性更新 + * 允许用户自定义会话标识,便于后期资产检索。 + * @method update + * @param {number} sessionId - 目标会话 ID + * @param {string} name - 新的会话标题 + */ + update: (sessionId: number, name: string) => { + return client.put(`/v1/sessions/${sessionId}`, { + session_id: sessionId, + session_name: name + }); + }, + + /** + * 1.5 彻底删除指定的会话记录 + * 注意:此操作通常会连带清理后端存储的对话历史缓存(Short-term memory)。 + * @method delete + * @param {number} sessionId - 待删除 ID + * @returns {Promise<{ success: boolean }>} + */ + delete: (sessionId: number) => { + return client.delete(`/v1/sessions/${sessionId}`); + }, + + /** + * 2.1 向 AI 代理集群发送自然语言指令 + * 系统将触发路由逻辑,根据内容分发给相应的 Agent 进行处理。 + * 若涉及库表生成,将进入异步 Schema 分析流。 + * @async + * @method sendMessage + * @param {number} sessionId - 会话上下文 ID + * @param {string} content - 用户输入的自然语言文本(如:“帮我加一个用户表”) + * @param {string} [model] - 可选:指定本次对话使用的推理模型 + * @returns {Promise} 包含代理反馈、生成的 SQL 片段及 UI 状态指令 + */ + sendMessage: (sessionId: number, content: string, model?: string) => { + // AI 调用可能需要较长时间(模型推理、网络延迟等),设置 120 秒超时 + // 这样可以确保前端等待后端完成处理,包括错误消息的持久化 + return client.post(`/v1/sessions/${sessionId}/messages`, { + content, + model + }, { + timeout: 120000 // 120秒超时,覆盖大多数AI模型响应场景 + }); + }, + + /** + * 2.2 获取当前会话的全量历史消息轨迹 + * 用于在前端对话窗口渲染气泡流,恢复之前的交流上下文。 + * @method getMessages + * @param {number} sessionId - 会话 ID + */ + getMessages: (sessionId: number) => { + // 返回 ChatResponse 数组,包含 User 与 Assistant 的交互记录 + return client.get(`/v1/sessions/${sessionId}/messages`); + }, + + /** + * 2.3 核心安全特性:确认并执行 AI 建议的消息指令 + * 实现 SF3 的安全确认逻辑。当 AI 生成删除、修改等 DML 语句时, + * 必须通过此接口手动触发物理执行。 + * @method confirmMessage + * @param {number} message_id - 待确认的 AI 消息节点 ID + * @returns {Promise} 执行结果反馈(含受影响行数或执行成功标识) + */ + confirmMessage: (messageId: number) => { + // 用户点击“确认执行”按钮后触发,将生成的临时 SQL 正式部署至物理数据库 + return client.post(`/v1/messages/${messageId}/confirm`); + }, + + /** + * 2.3.1 撤销或拒绝 AI 建议的消息指令 + * 实现“安全刹车”。若用户发现 AI 生成的逻辑有误,可通过此接口终止操作。 + * @method cancelMessage + * @param {number} messageId - 待撤销的消息 ID + */ + cancelMessage: (messageId: number) => { + // 终止执行并更新 UI 状态为“已取消”,防止误触发 + return client.post(`/v1/messages/${messageId}/cancel`); + }, + + /** + * 2.4 获取当前平台支持的 AI 代理推理模型清单 + * 实现迭代计划中的“多模型切换”特性。 + * 前端据此渲染模型切换下拉菜单。 + * @method getAIModelOptions + * @returns {Promise} 包含可用模型 ID 及展示名称的列表 + */ + getAIModelOptions: () => { + // 从后端动态调取当前已注册并在线的模型节点配置 + return client.get('/v1/ai-models/options'); + } +}; \ No newline at end of file diff --git a/src/frontend/src/api/user.ts b/src/frontend/src/api/user.ts new file mode 100644 index 0000000..8cdc811 --- /dev/null +++ b/src/frontend/src/api/user.ts @@ -0,0 +1,166 @@ +/** + * @file user.ts + * @module API/User-Profile + * @description 用户个人档案与安全中心 API 模块。 + * 本模块深度对接系统需求中的 SF10(个人账户管理)功能,为用户提供全方位的账户治理能力。 + * 核心职能: + * 1. 档案管理:支持个人基本信息(用户名、头像)的实时维护; + * 2. 安全加固:实现基于旧密码验证的密码变更流,以及基于邮件网关的身份二次核验; + * 3. 行为审计:对接登录历史追踪(Reliability-2),确保每一次账户准入均可追溯。 + * @author Wang Lirong (王利蓉) + * @version 1.5.0 + * @date 2026-01-02 + */ + +import client from './client.ts'; +import { PageData } from '../types.ts'; + +// --- 类型定义 (参考 OpenAPI Schema V1.3) --- + +/** + * 个人信息核心视图接口 + * @interface UserMe + * @description 反映当前登录用户的全维画像,包括基础属性与资源消耗状态。 + * @property {number} user_id - 系统内唯一识别码。 + * @property {string} username - 注册用户名。 + * @property {string} email - 绑定的主邮箱。 + * @property {string} status - 账户活跃策略:正常、挂起、封禁。 + * @property {number} used_databases - 当前已创建的物理数据库实例数。 + * @property {number} max_databases - 账户配额上限,受管理员动态调控。 + * @property {string | null} avatar_url - 托管在静态资源服务器上的头像地址。 + * @property {boolean} is_admin - 管理员权限标志。 + * @property {string | null} last_login_at - 最近一次鉴权成功的时间。 + * @property {string} created_at - 账号入库时间。 + */ +export interface UserMe { + user_id: number; + username: string; + email: string; + status: 'normal' | 'suspended' | 'banned'; + used_databases: number; + max_databases: number; + avatar_url: string | null; + is_admin: boolean; + last_login_at: string | null; + created_at: string; +} + +/** + * 修改密码请求参数接口 + * @interface UpdatePasswordParams + * @property {string} old_password - 原始密码,用于后端进行身份再确认。 + * @property {string} new_password - 拟设定的新密码,需符合复杂度校验。 + * @property {string} confirm_password - 用于前端预校对的重复输入。 + */ +export interface UpdatePasswordParams { + old_password: string; + new_password: string; + confirm_password: string; +} + +/** + * 变更邮箱请求参数接口 + * @interface UpdateEmailParams + * @property {string} new_email - 待绑定的新邮箱地址。 + * @property {string} code - 从新邮箱获取的临时验证码。 + */ +export interface UpdateEmailParams { + new_email: string; + code: string; +} + +/** + * 登录历史审计条目定义 + * @interface LoginHistoryItem + * @description 对应系统审计日志,记录每次登录的设备环境与最终状态。 + */ +export interface LoginHistoryItem { + login_id: number; + login_time: string; + logout_time: string | null; + ip_address: string; + user_agent: string | null; + login_status: 'success' | 'failed' | 'expired' | 'forced_logout'; +} + +// --- API 方法定义 --- + +/** + * 1. 检索当前在线用户的完整档案 + * 该接口通常在应用初始化及个人中心加载时调用,以同步最新的资源配额。 + * @method getUserProfile + * @returns {Promise} 返回当前用户详情 + */ +export const getUserProfile = () => { + return client.get('/v1/user/me'); +}; + +/** + * 2. 修改用户个性化展示名称 + * 实现对 username 字段的局部更新。 + * @method updateUsername + * @param {string} username - 新的用户名 + * @returns {Promise} 返回更新后的用户对象 + */ +export const updateUsername = (username: string) => { + // 发起 PATCH 请求进行增量字段修改 + return client.patch('/v1/user/me', { username }); +}; + +/** + * 3. 账户安全:更新登录密码 + * 后端将验证旧密码。若验证失败,则抛出 400 系列业务错误。 + * @method updatePassword + * @param {UpdatePasswordParams} data - 包含新旧密码的数据载荷 + */ +export const updatePassword = (data: UpdatePasswordParams) => { + // 使用 PUT 动词执行密码资源的强制覆盖更新 + return client.put('/v1/user/me/password', data); +}; + +/** + * 4. 更新用户个性化头像 + * 接受已上传至存储服务后的 URL 地址,并更新至用户元数据中。 + * @method updateAvatar + * @param {string} avatarUrl - 头像的 CDN 或存储地址 + */ +export const updateAvatar = (avatarUrl: string) => { + return client.post('/v1/user/me/avatar', { avatar_url: avatarUrl }); +}; + +/** + * 5. 安全辅助:发送邮箱变更验证码 + * 向新申请的邮箱地址发送 6 位验证码,开启邮箱变更事务。 + * @method sendEmailVerificationCode + * @param {string} newEmail - 新的目标邮箱地址 + */ +export const sendEmailVerificationCode = (newEmail: string) => { + // 触发后端邮件网关服务 + return client.post('/v1/user/me/email/send-code', { new_email: newEmail }); +}; + +/** + * 6. 确认并完成邮箱变更逻辑 + * 验证用户输入的验证码。验证通过后,系统将正式切换绑定的邮箱字段。 + * @method confirmUpdateEmail + * @param {UpdateEmailParams} data - 包含新邮箱及验证码的 payload + */ +export const confirmUpdateEmail = (data: UpdateEmailParams) => { + return client.put('/v1/user/me/email', data); +}; + +/** + * 7. 检索个人登录审计轨迹 (分页模式) + * 对应非功能性需求中的“故障后追溯”要求。 + * 用户可在安全设置中查看近期的登录设备与 IP 是否存在异常。 + * @method getLoginHistory + * @param {number} [page=1] - 当前请求页码 + * @param {number} [pageSize=10] - 每页审计记录条数,默认设定为 10 条 + * @returns {Promise>} 包含审计历史的分页响应对象 + */ +export const getLoginHistory = (page: number = 1, pageSize: number = 10) => { + // 构建带有分页偏移参数的 GET 请求 + return client.get>('/v1/user/me/login-history', { + params: { page, page_size: pageSize } + }); +}; \ No newline at end of file diff --git a/src/frontend/src/components/ConfirmDialog.tsx b/src/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..ccdf9bd --- /dev/null +++ b/src/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,147 @@ +/** + * @file ConfirmDialog.tsx + * @module Components/Feedback/Confirm-Dialog + * @description 基于 React 的原子化统一确认对话框组件。 + * 本组件作为系统交互层的“安全闸门”,替代了不可控的浏览器原生 confirm() 接口。 + * * 核心设计目标: + * [cite_start]1. 安全确认机制 (Usability-2): 针对物理数据库删除、DML 指令执行等高风险操作提供二次确认逻辑 [cite: 617]; + * 2. 交互一致性: 采用高斯模糊背景与平滑动效,确保系统视觉语言的统一; + * 3. 语义化视觉反馈: 支持危险操作(Danger)与普通操作(Primary)的视觉分级。 + * * 对应需求点: + * - [cite_start]Security-3: 对 AI 生成的 SQL 删除/更新操作强制确认 [cite: 555-556]; + * - Usability-2: 提供明确的双重确认提示以防止非技术用户误操作。 + * * @author Wang Lirong (王利蓉) + * @version 2.0.0 + * @date 2026-01-02 + */ + +import React from 'react'; +import { AlertTriangle, X } from 'lucide-react'; +import { Button } from './UI'; + +/** + * 确认对话框组件属性接口 + * @interface ConfirmDialogProps + * @description 封装了对话框的展示内容、交互行为及视觉风格配置。 + */ +interface ConfirmDialogProps { + /** * 控制对话框的渲染状态 + * true 时展示遮罩与内容,false 时不渲染任何 DOM 节点。 + */ + isOpen: boolean; + /** * 关闭对话框的回调函数 + * 触发场景:点击取消按钮、右上角关闭按钮或遮罩层。 + */ + onClose: () => void; + /** * 用户执行“确认”动作后的业务回调 + * 该函数通常包含实际的 DDL/DML 提交逻辑或项目删除指令。 + */ + onConfirm: () => void; + /** 对话框顶部的标题文本,需简明扼要说明操作意图 */ + title: string; + /** 对话框的核心描述内容,用于向用户解释该操作的后果及风险 */ + message: string; + /** 确认按钮的自定义文本,默认为“确认” */ + confirmText?: string; + /** 取消按钮的自定义文本,默认为“取消” */ + cancelText?: string; + /** * 视觉风险标识开关 + * 设置为 true 时,确认按钮将应用红色警示色调,适用于不可逆的销毁操作。 + */ + isDangerous?: boolean; + /** * 警告图标显隐控制 + * 启用后会在标题左侧显示橙色警示符号,增强用户视觉注意。 + */ + showWarningIcon?: boolean; +} + +/** + * 统一确认对话框组件实体 + * @component ConfirmDialog + * @description + * 采用 React 函数式组件构建,结合 Tailwind CSS 实现全分辨率适配与平滑过渡。 + * 组件层级设计遵循 Z-Index 规范(z-[60]),确保其始终浮动在全局蒙层与侧边栏之上。 + */ +export const ConfirmDialog: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText = '确认', + cancelText = '取消', + isDangerous = false, + showWarningIcon = false, +}) => { + /** + * 步骤 1:条件渲染守卫 + * 若对话框处于关闭状态,立即终止渲染流以优化虚拟 DOM 性能。 + */ + if (!isOpen) return null; + + /** + * 步骤 2:封装确认逻辑 + * 在执行业务回调的同时自动关闭当前模态框,保证交互闭环。 + */ + const handleConfirm = () => { + // 触发父级透传的业务指令(如 API 调用) + onConfirm(); + // 逻辑流完成后关闭弹窗 + onClose(); + }; + + /** + * 步骤 3:视觉布局构建 (Atomic Layout) + * 采用 Fixed 布局覆盖全屏,配合 backdrop-blur 实现现代 UI 的通透感。 + */ + return ( + // 全屏遮罩层:提供 60% 透明度的深色背景及毛玻璃滤镜 +
+ + {/* 对话框主体容器:具备自适应宽度及进入动画 (animate-in) */} +
+ + {/* 对话框头部 (Header Section) */} +
+ {/* 标题区域:包含可选的警告图标与截断保护文本 */} +

+ {/* 条件渲染:显示警示图标以提示操作风险 */} + {showWarningIcon && } + {title} +

+ + {/* 交互控件:右上角关闭图标,提供圆角悬停反馈 */} + +
+ + {/* 内容主体区域 (Content Section) */} +
+ {/* 渲染业务描述文本,使用宽松的行高 (leading-relaxed) 以提升阅读舒适度 */} +

{message}

+
+ + {/* 底部操作栏 (Footer Section) */} +
+ {/* 取消动作:默认变体按钮 */} + + + {/* 确认动作:根据 isDangerous 参数动态切换按钮视觉语义 (Danger vs Primary) */} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx new file mode 100644 index 0000000..7175bf8 --- /dev/null +++ b/src/frontend/src/components/Header.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Menu } from 'lucide-react'; + +/** + * 顶部导航栏属性接口 + */ +interface HeaderProps { + /** 切换侧边栏显示状态 */ + onToggleSidebar: () => void; +} + +/** + * 全局顶部导航栏组件 + * * 包含用户头像、名称展示以及下拉菜单(个人设置、退出登录)。 + * 实现了点击外部区域自动关闭下拉菜单的逻辑。 + * + * @param {HeaderProps} props - 组件属性 + * @returns {JSX.Element} 顶部栏元素 + */ +export const Header: React.FC = ({ onToggleSidebar }) => { + return ( +
+
+ +
+
+ ); +}; \ No newline at end of file diff --git a/src/frontend/src/components/InteractiveERRenderer.tsx b/src/frontend/src/components/InteractiveERRenderer.tsx new file mode 100644 index 0000000..d23f9ef --- /dev/null +++ b/src/frontend/src/components/InteractiveERRenderer.tsx @@ -0,0 +1,566 @@ +/** + * @file InteractiveERRenderer.tsx + * @module Components/Visualization/ER-Diagram + * @description 交互式实体关系图(ER Diagram)高性能渲染引擎。 + * 本组件作为“基于大模型多智能体框架的数据库自动部署与库表生成系统”的可视化中枢, + * 承担了从 AI 生成的 Mermaid DSL 到交互式 SVG 图形的渲染转化职能。 + * * * 核心设计目标: + * [cite_start]1. 动态自省渲染 (SF5): 实时解析底层 Schema Agent 生成的表结构元数据 [cite: 85-87]; + * 2. 交互式操作 (Usability-1): 提供 0.2x 至 3x 的平滑缩放及全向位移(Panning)功能; + * 3. 渲染稳定性: 采用离屏 DOM 预渲染技术,彻底消除浏览器重绘期间产生的视觉抖动; + * 4. 性能保障: 针对大规模复杂 ER 图(如教务管理系统),通过 Passive Event 处理优化滚动性能。 + * * * 关键技术路径: + * - Mermaid.js 核心引擎驱动; + * - 响应式 SVG 视图矩阵变换; + * - 二分法缩放比例修正算法。 + * * @author Wang Lirong (王利蓉) + * @version 2.5.0 + * @date 2026-01-02 + */ + +import React, { useEffect, useRef, useState, useCallback } from 'react'; + +/** + * 交互式 ER 渲染器属性接口定义 + * @interface InteractiveERRendererProps + * @property {string} chart - 原始 Mermaid 语法的 ER 定义文本流 + * @property {string} [className] - 可选的外部容器样式扩展类名 + */ +interface InteractiveERRendererProps { + chart: string; + className?: string; +} + +/** + * @component InteractiveERRenderer + * @description + * 一个具备高度自律性的 React 函数式组件。 + * 内部通过引用状态锁(Ref Locks)解决了 React 闭包陷阱与原生事件监听器之间的数据同步冲突。 + */ +export const InteractiveERRenderer: React.FC = ({ chart, className = '' }) => { + // --- 物理层 DOM 引用 --- + /** 外层布局容器引用:用于确定组件的物理边界 */ + const containerRef = useRef(null); + /** SVG 挂载点引用:动态生成的 SVG 节点将注入此容器 */ + const svgContainerRef = useRef(null); + /** 活跃 SVG 元素引用:用于直接执行矩阵变换 (transform) */ + const svgRef = useRef(null); + + // --- 几何变换状态机 --- + /** 缩放比例状态:1 为原始尺寸,支持 0.2 至 3 的动态区间 */ + const [scale, setScale] = useState(1); + /** 坐标位移状态:存储当前的 X/Y 偏移量 */ + const [position, setPosition] = useState({ x: 0, y: 0 }); + + // --- 交互控制流状态 --- + /** 拖拽锁:标识用户当前是否正在执行物理抓取动作 */ + const [isDragging, setIsDragging] = useState(false); + /** 拖拽起始锚点:记录鼠标按下时的瞬时坐标偏移 */ + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + /** 加载生命周期标识:控制遮罩层与加载动画的协同展示 */ + const [isLoading, setIsLoading] = useState(true); + + // --- 引用一致性保障 (Ref-Sync Mechanism) --- + /** * @description 关键架构设计: + * 在 React 异步更新模式下,事件监听器(如 wheel)往往会捕获到旧的闭包数据。 + * 通过 ref 实时映射最新的状态值,确保非 React 控制的事件流能获取到最实时的几何参数。 + */ + const scaleRef = useRef(scale); + const positionRef = useRef(position); + + /** 状态到引用的单向同步:Scale */ + useEffect(() => { + scaleRef.current = scale; + }, [scale]); + + /** 状态到引用的单向同步:Position */ + useEffect(() => { + positionRef.current = position; + }, [position]); + + /** + * 核心渲染管线 Effect + * 负责 Mermaid 引擎的异步初始化、DSL 修正、离屏渲染及 SVG 挂载。 + */ + useEffect(() => { + // 准入检查:若无输入数据或容器未就绪,则跳过本次渲染周期 + if (!chart || !svgContainerRef.current) return; + + // 开启加载状态,锁定 UI 交互 + setIsLoading(true); + + /** + * 内部异步渲染主函数 + */ + const renderER = async () => { + /** + * 步骤 1:离屏渲染容器构建 + * @description + * Mermaid 在渲染期间会在 body 下创建临时 ID 冲突检测元素。 + * 采用“绝对定位偏移法”创建一个隐形的离屏容器,将副作用限制在可见视口之外, + * 从而解决由于 CSS 样式计算导致的页面布局突发性跳变。 + */ + const offscreenContainer = document.createElement('div'); + offscreenContainer.style.cssText = 'position: fixed; left: -9999px; top: -9999px; visibility: hidden; pointer-events: none;'; + document.body.appendChild(offscreenContainer); + + try { + /** + * 步骤 2:DSL 预处理与格式清洗 + * 针对 AI 模型生成的原始文本进行语法补全,确保后端返回的多行定义能被正确识别。 + */ + let fixedChart = chart.trim(); + // 修正特定 AI 模型的输出错误,确保 erDiagram 标识符具有正确的换行前缀 + if (fixedChart.includes('}}%% erDiagram')) { + fixedChart = fixedChart.replace('}}%% erDiagram', '}}%%\nerDiagram'); + } + + /** + * 步骤 3:Mermaid 引擎动态加载 + * 采用 Dynamic Import 策略实现 Code-splitting,减小首屏 Bundle 体积。 + */ + const mermaid = (await import('mermaid')).default; + + /** + * 步骤 4:引擎参数初始化配置 + * 对应需求说明书中的“视觉美化”要求,定制 ER 图的排版方向、颜色及字体大小。 + */ + mermaid.initialize({ + startOnLoad: false, + theme: 'default', + securityLevel: 'loose', // 允许复杂的交互属性 + er: { + diagramPadding: 30, // 图表外边距 + layoutDirection: 'TB', // 采用自上而下的逻辑流向 + minEntityWidth: 150, // 实体矩形最小宽度 + minEntityHeight: 100, // 实体矩形最小高度 + entityPadding: 25, // 字段间的呼吸感间距 + stroke: '#333', // 连接线颜色 + fill: '#f8f9fa', // 实体背景色 + fontSize: 16, // 业务字体大小适配 + useMaxWidth: false // 禁用自动宽度,由本组件接管几何变换 + } + }); + + /** + * 步骤 5:离屏异步渲染 + * 生成唯一的 ID 标识符,防止多组件实例下的 SVG ID 污染。 + */ + const id = `interactive-er-${Date.now()}`; + const result = await mermaid.render(id, fixedChart, offscreenContainer); + + // 步骤 6:DOM 注入与 SVG 后置处理 + if (svgContainerRef.current) { + svgContainerRef.current.innerHTML = result.svg; + + // 获取并接管 SVG 根节点 + const svg = svgContainerRef.current.querySelector('svg'); + if (svg) { + svgRef.current = svg; + + // 注入交互式 CSS 属性 + svg.style.width = '100%'; + svg.style.height = '100%'; + svg.style.cursor = 'grab'; // 初始手势状态 + svg.style.userSelect = 'none'; // 禁止文本选中干扰拖拽 + + /** + * 步骤 7:ViewBox 弹性补偿 + * 确保导出的 SVG 具备正确的视口定义。若引擎未自动生成, + * 则利用 getBBox 测量物理尺寸并动态补全,防止图像显示不全。 + */ + if (!svg.getAttribute('viewBox')) { + try { + const bbox = svg.getBBox(); + svg.setAttribute('viewBox', `0 0 ${bbox.width + 60} ${bbox.height + 60}`); + } catch (e) { + // 异常兜底:应用标准 4:3 比例视口 + svg.setAttribute('viewBox', '0 0 800 600'); + } + } + + // 步骤 8:重置变换坐标系 + // 每次内容更新后,回归初始视角,确保用户能看到全貌 + setScale(1); + setPosition({ x: 0, y: 0 }); + updateTransform(svg, 1, { x: 0, y: 0 }); + } + + console.log('✅ 交互式 ER 图渲染完成!'); + setIsLoading(false); + } + } catch (error) { + // 异常处理:在容器内渲染错误提示 UI,替代原始图形展示 + console.error('❌ ER图渲染失败:', error); + if (svgContainerRef.current) { + svgContainerRef.current.innerHTML = ` +
+

渲染失败

+

${error}

+
+ `; + } + setIsLoading(false); + } finally { + // 资源回收:销毁临时的离屏容器 + if (offscreenContainer.parentNode) { + offscreenContainer.parentNode.removeChild(offscreenContainer); + } + } + }; + + renderER(); + }, [chart]); // 当图表定义变更时,触发完整的重绘管线 + + /** + * 执行物理矩阵变换 + * 将逻辑层级的 scale 和 position 映射为 CSS 渲染层的 2D 变换。 + * @param {SVGElement} svg - 目标图形节点 + * @param {number} newScale - 计算后的目标缩放比 + * @param {Object} newPosition - 目标位移向量 + */ + const updateTransform = useCallback((svg: SVGElement, newScale: number, newPosition: { x: number, y: number }) => { + // 采用 translate + scale 的复合变换 + svg.style.transform = `translate(${newPosition.x}px, ${newPosition.y}px) scale(${newScale})`; + // 设置变换锚点为中心,符合用户直觉 + svg.style.transformOrigin = 'center center'; + }, []); + + /** + * 滚轮事件监听效应 + * 实现基于鼠标滚轮的动态无损缩放。 + */ + useEffect(() => { + const container = svgContainerRef.current; + if (!container) return; + + /** + * 滚轮交互核心逻辑 + * @param {WheelEvent} e - 原生浏览器滚轮事件 + */ + const handleWheel = (e: WheelEvent) => { + // 准入条件:必须同时存在 SVG 实例及父容器 + if (!svgRef.current || !svgContainerRef.current) return; + + // 步骤 1:坐标空间判定 + const containerRect = svgContainerRef.current.getBoundingClientRect(); + const mouseX = e.clientX; + const mouseY = e.clientY; + + // 判定鼠标是否在 ER 渲染有效负载区内 + const isMouseInContainer = ( + mouseX >= containerRect.left && + mouseX <= containerRect.right && + mouseY >= containerRect.top && + mouseY <= containerRect.bottom + ); + + // 逻辑分流:若鼠标不在容器内,释放控制权,允许父级页面正常滚动 + if (!isMouseInContainer) { + return; + } + + /** + * 步骤 2:事件拦截 + * 使用 non-passive 模式调用 preventDefault,防止缩放过程中页面整体产生偏移。 + */ + e.preventDefault(); + e.stopPropagation(); + + /** + * 步骤 3:缩放因子计算 + * 采用 0.9/1.1 的几何级数系数,确保缩放过程在视觉上具备线性感受。 + */ + const delta = e.deltaY > 0 ? 0.9 : 1.1; + // 从 ref 中读取最新几何参数,避免闭包失效 + const currentScale = scaleRef.current; + const currentPosition = positionRef.current; + + // 步骤 4:范围约束检查 (Clamping) + // 限制最小 0.2 倍(概览模式),最大 3 倍(微观模式) + const newScale = Math.max(0.2, Math.min(3, currentScale * delta)); + + // 步骤 5:触发状态同步与视图变换 + setScale(newScale); + updateTransform(svgRef.current, newScale, currentPosition); + }; + + /** + * 关键优化点: + * 手动绑定并声明 passive: false,以绕过 Chrome 的高性能滚动默认拦截。 + */ + container.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + container.removeEventListener('wheel', handleWheel); + }; + }, [updateTransform]); + + /** + * 开始拖动交互流程 + * @param {React.MouseEvent} e - React 封装的鼠标事件 + */ + const handleMouseDown = (e: React.MouseEvent) => { + if (!svgRef.current || !svgContainerRef.current) return; + + // 步骤 1:热区点击判定 + const containerRect = svgContainerRef.current.getBoundingClientRect(); + const mouseX = e.clientX; + const mouseY = e.clientY; + + const isMouseInContainer = ( + mouseX >= containerRect.left && + mouseX <= containerRect.right && + mouseY >= containerRect.top && + mouseY <= containerRect.bottom + ); + + if (!isMouseInContainer) { + return; + } + + // 步骤 2:阻止父级组件感知此交互(如侧边栏收起等) + e.stopPropagation(); + + // 步骤 3:开启位移捕捉模式 + setIsDragging(true); + // 记录初始位移偏移量,公式:dragStart = mousePos - currentPos + setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }); + // 切换视觉指针样式 + svgRef.current.style.cursor = 'grabbing'; + }; + + /** + * 位移跟踪逻辑 + * @param {React.MouseEvent} e + */ + const handleMouseMove = (e: React.MouseEvent) => { + // 若未处于拖拽状态或 SVG 未加载,立即短路返回 + if (!isDragging || !svgRef.current) return; + + // 阻止浏览器默认文本选择干扰 + e.stopPropagation(); + + // 步骤 1:计算目标位移向量 + const newPosition = { + x: e.clientX - dragStart.x, + y: e.clientY - dragStart.y + }; + + // 步骤 2:实时刷新状态与视图 + setPosition(newPosition); + updateTransform(svgRef.current, scale, newPosition); + }; + + /** + * 终止拖动交互流 + * @param {React.MouseEvent} [e] - 可选的事件对象 + */ + const handleMouseUp = (e?: React.MouseEvent) => { + if (!svgRef.current) return; + + if (e) { + e.stopPropagation(); + } + + // 重置交互标志位 + setIsDragging(false); + // 恢复抓取状态指针 + svgRef.current.style.cursor = 'grab'; + }; + + /** + * 全局视图复位器 + * 一键回归原始 1.0 比例及 (0,0) 原点坐标。 + */ + const resetView = () => { + if (!svgRef.current) return; + + const newScale = 1; + const newPosition = { x: 0, y: 0 }; + + setScale(newScale); + setPosition(newPosition); + updateTransform(svgRef.current, newScale, newPosition); + }; + + /** + * 渲染 JSX 结构 + * 对应项目前端架构中的可视化面板。 + */ + return ( +
+ + {/* 浮动控制工具栏 (HUD): + 包含放大、缩小及复位功能,常驻于图表右上角。 + */} +
+ {/* 缩小触发器 */} + + {/* 复位首页触发器 */} + + {/* 放大触发器 */} + +
+ + {/* HUD - 状态信息栏: + 实时显示当前的物理缩放百分比。 + */} +
+ {Math.round(scale * 100)}% +
+ + {/* 核心画布容器 (Main Canvas): + 采用高阴影、圆角化设计,符合现代分析仪表盘视觉规范。 + */} +
+ {/* 加载蒙层逻辑: + 在异步渲染期间提供平滑的等待反馈。 + */} +
+
+ {/* CSS 纯代码驱动的加载动画旋转体 */} +
+ 正在渲染交互式 ER 图... +
+
+ + {/* SVG 挂载宿主容器: + 渲染完成后,通过透明度渐变实现平滑入场。 + 绑定原生鼠标交互监听器,接管 Canvas 级别的所有操作。 + */} +
handleMouseUp(e)} + onMouseLeave={() => handleMouseUp()} + style={{ + position: 'absolute', + inset: 0, + touchAction: 'none', // 禁用系统默认触摸手势,由组件接管控制 + opacity: isLoading ? 0 : 1, + visibility: isLoading ? 'hidden' : 'visible', + transition: 'opacity 0.2s ease-in-out' + }} + /> + + {/* 局部样式定义: + 定义全局公用的 Loading Keyframe 帧动画。 + */} + +
+
+ ); +}; + +export default InteractiveERRenderer; \ No newline at end of file diff --git a/src/frontend/src/components/Pagination.tsx b/src/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..5a0fead --- /dev/null +++ b/src/frontend/src/components/Pagination.tsx @@ -0,0 +1,306 @@ +/** + * @file Pagination.tsx + * @module Components/Navigation/Pagination + * @description 通用数据分页组件。 + * 本组件作为系统“数据密集型”界面的核心导航工具,负责驱动长列表的按需加载逻辑。 + * * 核心设计目标: + * 1. 性能优化 (Performance-1): 通过分页机制减轻单次 API 请求的数据载荷,降低首屏渲染耗时; + * 2. 响应式布局: 支持“简洁模式”与“完整模式”自动切换,适配从移动端到桌面端的不同屏幕宽度; + * 3. 交互友好 (Usability-1): 提供页码跳转、首末页快速锚定及省略号(Ellipsis)智能计算; + * 4. 状态受控: 严格遵循 React 受控组件模式,通过 onChange 回调同步外部状态。 + * * 对应需求点: + * - SF2: 项目列表的分页展示; + * - SF9: 历史查询记录的翻页溯源。 + * * @author Wang Lirong (王利蓉) + * @version 2.1.0 + * @date 2026-01-02 + */ + +import React from 'react'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; + +/** + * 分页组件属性接口定义 + * @interface PaginationProps + */ +interface PaginationProps { + /** 当前活跃页码(基于 1 的索引) */ + current: number; + /** 系统中符合过滤条件的条目总数 */ + total: number; + /** 每页预设的显示数据行数 */ + pageSize: number; + /** 页码发生变更时的回调函数,参数为目标页码 */ + onChange: (page: number) => void; + /** 是否在组件左侧展示“显示第 X 到 Y 条”的汇总信息 */ + showTotal?: boolean; + /** 是否展示快速跳转至特定页码的输入框 */ + showQuickJumper?: boolean; + /** 是否启用极简模式(仅保留“当前/总计”及翻页箭头) */ + simple?: boolean; + /** 允许外部注入的自定义 CSS 类名 */ + className?: string; + /** 禁用状态标识:为 true 时禁止所有点击交互 */ + disabled?: boolean; +} + +/** + * 响应式通用分页组件实体 + * @component Pagination + */ +export const Pagination: React.FC = ({ + current, + total, + pageSize, + onChange, + showTotal = true, + showQuickJumper = false, + simple = false, + className = '', + disabled = false, +}) => { + /** * 步骤 1:基础分页元数据计算 + * 基于向上取整算法计算总页数,确保即使只有一条数据也能显示第一页。 + */ + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + /** 计算当前页面起始条目的绝对序号 */ + const startItem = total === 0 ? 0 : (current - 1) * pageSize + 1; + + /** 计算当前页面结束条目的绝对序号(取 pageSize 与 total 的最小值,防止溢出) */ + const endItem = Math.min(current * pageSize, total); + + /** + * 步骤 2:核心算法 - 生成页码按钮序列 + * @description + * 实现“滑动窗口”分页算法。当总页数过多时,自动在中间插入省略号(ellipsis), + * 保持组件在 UI 上的固定宽度,提升视觉稳定性。 + * @returns {(number | 'ellipsis')[]} 包含页码数字和省略号占位符的混合数组 + */ + const getPageNumbers = (): (number | 'ellipsis')[] => { + const pages: (number | 'ellipsis')[] = []; + const maxVisible = 5; // 窗口内最多展示的连续数字按钮数 + + // 分支 A:总页数较少时,直接平铺展示所有页码 + if (totalPages <= maxVisible + 2) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // 分支 B:总页数较多,执行复杂的省略号计算逻辑 + pages.push(1); // 始终保留首页 + + if (current <= 3) { + // 子分支 B1:当前页靠近序列开头 + for (let i = 2; i <= Math.min(4, totalPages - 1); i++) { + pages.push(i); + } + // 在尾部之前插入省略号 + if (totalPages > 5) pages.push('ellipsis'); + } else if (current >= totalPages - 2) { + // 子分支 B2:当前页靠近序列结尾 + // 在首部之后插入省略号 + if (totalPages > 5) pages.push('ellipsis'); + for (let i = Math.max(totalPages - 3, 2); i < totalPages; i++) { + pages.push(i); + } + } else { + // 子分支 B3:当前页处于中间区域 + // 双向插入省略号,仅保留当前页及其前后相邻页 + pages.push('ellipsis'); + for (let i = current - 1; i <= current + 1; i++) { + pages.push(i); + } + pages.push('ellipsis'); + } + + pages.push(totalPages); // 始终保留末页 + } + + return pages; + }; + + /** + * 步骤 3:页码变更事件处理器 + * 包含前置守卫:拦截非法页码、禁用状态以及冗余的重复跳转。 + * @param {number} page - 目标页码 + */ + const handlePageChange = (page: number) => { + if (disabled || page < 1 || page > totalPages || page === current) return; + onChange(page); + }; + + /** + * 渲染分支 A:简洁模式 (Simple Mode) + * 适用于侧边栏、移动端或空间受限的局部容器。 + */ + if (simple) { + return ( +
+ {/* 数据摘要展示 */} + {showTotal && ( + + {total > 0 ? `${startItem}-${endItem} / ${total}` : '暂无数据'} + + )} + + {/* 简洁导航控件组 */} +
+ {/* 上一页触发器 */} + + + {/* 当前进度的文本描述(例:2 / 10) */} + + {current} / {totalPages} + + + {/* 下一页触发器 */} + +
+
+ ); + } + + /** + * 渲染分支 B:完整模式 (Standard Mode) + * 包含完整的数字页码选择器、首末页快速锚定及总数详细描述。 + */ + return ( +
+ + {/* 步骤 4.1:总数详细统计信息 (Total Summary) */} + {showTotal && ( +
+ {total > 0 ? ( + <> + 显示 {startItem} 到{' '} + {endItem} 条,共{' '} + {total} 条 + + ) : ( + '暂无数据' + )} +
+ )} + + {/* 步骤 4.2:分页控制按钮群 (Pagination Control Group) */} +
+ + {/* 首页快捷键:在移动端自动隐藏以节省空间 */} + + + {/* 上一页按钮 */} + + + {/* 核心:动态页码渲染区域 */} +
+ {getPageNumbers().map((page, index) => + // 渲染省略号占位符 + page === 'ellipsis' ? ( + + ··· + + ) : ( + // 渲染具体的页码数字按钮 + + ) + )} +
+ + {/* 下一页按钮 */} + + + {/* 末页快捷键 */} + +
+ + {/* 步骤 4.3:快速跳转模块 (Quick Jumper) + 允许用户通过键盘输入直接定位至目标页面,提升在海量数据下的导航效率。 + */} + {showQuickJumper && totalPages > 5 && ( +
+ 跳至 + { + // 监听回车键,执行跳转逻辑 + if (e.key === 'Enter') { + const value = parseInt((e.target as HTMLInputElement).value); + if (!isNaN(value)) { + // 执行带边界约束的页码跳转 + handlePageChange(Math.min(Math.max(1, value), totalPages)); + // 执行完成后清空输入框,优化交互流 + (e.target as HTMLInputElement).value = ''; + } + } + }} + aria-label="跳转到指定页" + /> + +
+ )} +
+ ); +}; + +export default Pagination; \ No newline at end of file diff --git a/src/frontend/src/components/PanelToggleButton.tsx b/src/frontend/src/components/PanelToggleButton.tsx new file mode 100644 index 0000000..3fd1e65 --- /dev/null +++ b/src/frontend/src/components/PanelToggleButton.tsx @@ -0,0 +1,120 @@ +/** + * @file PanelToggleButton.tsx + * @module Components/Layout/Panel-Toggle + * @description 布局面板切换控制器组件。 + * 本组件是系统“工作台(Workspace)”响应式布局的核心交互组件,负责管理左右功能面板的可视化切换。 + * * 核心设计原则: + * 1. 易用性适配 (Usability-1): 针对非技术用户,提供明确的视觉反馈与交互手势指引; + * 2. 无障碍合规 (A11y): 严格执行 WCAG 2.1 AA 标准,确保在极端缩放环境下仍具备 44x44px 的最小可点击区域; + * 3. 语义化提示: 动态生成中文标题(Tooltip),辅助用户识别面板的具体业务职能。 + * * 对应需求点: + * - Requirement 6.1: 侧边栏折叠与展开动效; + * - Requirement 6.2: 布局空间动态分配逻辑; + * - Requirement 6.4/6.5: 视口宽度监听下的自动收缩策略。 + * * @author Wang Lirong (王利蓉) + * @version 1.3.0 + * @date 2026-01-02 + */ + +import React from 'react'; +import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'; + +/** + * 面板切换按钮组件属性接口 + * @interface PanelToggleButtonProps + * @description 封装了面板控制器的状态锁及展示配置。 + */ +interface PanelToggleButtonProps { + /** 标识当前受控面板是否处于“物理展开”状态 */ + isOpen: boolean; + /** 触发面板状态反转的业务回调函数,通常由上层状态机驱动 */ + onToggle: () => void; + /** * 指定按钮在视口中的逻辑方位: + * - 'left': 对应数据库架构面板; + * - 'right': 对应会话记录与 AI 交互面板。 + */ + position: 'left' | 'right'; + /** 可选的自定义提示文本,若缺省则由组件根据语义自动生成 */ + title?: string; +} + +/** + * @component PanelToggleButton + * @description + * 采用 React 函数式组件构建。该组件通过封装位操作逻辑,实现了在单一按钮上展示四种状态图标的能力。 + * 样式层基于 Tailwind CSS,确保了在 500% 缩放比例下依然保持像素级的对齐精度。 + */ +export const PanelToggleButton: React.FC = ({ + isOpen, + onToggle, + position, + title +}) => { + /** + * 图标路由算法 + * @description 基于“方位”与“状态”的二元判定,从 Lucide 图标库中检索最优视觉符号。 + * * 逻辑分支: + * 1. 左侧面板展开:显示 PanelLeftClose(折叠暗示); + * 2. 左侧面板折叠:显示 PanelLeftOpen(展开暗示); + * 3. 右侧面板同理镜像处理。 + * @returns {JSX.Element} 对应的 SVG 矢量图标节点 + */ + const getIcon = () => { + // 处理左侧锚点逻辑 + if (position === 'left') { + return isOpen ? : ; + } + // 处理右侧锚点逻辑(镜像翻转) + else { + return isOpen ? : ; + } + }; + + /** + * 标题语义化生成器 + * @description 自动为辅助技术(如屏幕阅读器)生成业务描述,增强系统的容错性。 + * @returns {string} 符合当前面板职能的中文描述文本 + */ + const getDefaultTitle = () => { + if (position === 'left') { + // 对应左侧:通常挂载数据库 Schema 浏览器 + return isOpen ? '最小化数据库面板' : '展开数据库面板'; + } else { + // 对应右侧:通常挂载 Agent 会话历史列表 + return isOpen ? '最小化会话列表' : '展开会话列表'; + } + }; + + /** + * 组件渲染输出 + * 采用标准 button 元素,确保键盘可访问性(Tab-Index)。 + */ + return ( + + ); +}; \ No newline at end of file diff --git a/src/frontend/src/components/ProjectWizard.tsx b/src/frontend/src/components/ProjectWizard.tsx new file mode 100644 index 0000000..68bee62 --- /dev/null +++ b/src/frontend/src/components/ProjectWizard.tsx @@ -0,0 +1,767 @@ +/** + * @file ProjectWizard.tsx + * @module Components/Workflows/Project-Creation-Wizard + * @description 数据库项目初始化向导核心组件。 + * 本组件采用分阶段状态机架构,驱动从自然语言需求到物理数据库部署的全生命周期。 + * * * 核心业务特性: + * 1. 异步阶段流 (SF2): 深度集成 INITIALIZING -> SCHEMA -> DDL -> DEPLOY 的流水线; + * 2. 人工介入审计 (SF3): 在关键节点提供文本编辑器,允许用户修正 AI 生成的逻辑偏差; + * 3. 视觉进度欺骗 (UX Optimization): 采用渐进式异步增长算法,解决 AI 推理耗时不确定导致的白屏焦虑; + * 4. 容灾恢复: 基于 localStorage 实现向导进度的断点续连。 + * * * 技术亮点: + * - 双向同步 Ref 锁:解决 React 闭包陷阱导致的轮询状态回退问题; + * - 动态 Footer 劫持:通过 Render Prop 模式实现模态框底部的动态上下文控制; + * - 离屏 ER 图同步:在 Schema 变更时触发局部异步刷新。 + * * @author Wang Lirong (王利蓉) + * @version 3.2.0 + * @date 2026-01-02 + */ + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { ProjectDTO, CreationStageEnum, ProjectStatusEnum } from '../types'; +import { getProjectDetail, generateDDL, deployProject, regenerateER } from '../api/project'; +import { Button, ProgressBar } from './UI'; +import { Loader2, CheckCircle2, AlertTriangle, FileJson, Code2, Play, RefreshCw } from 'lucide-react'; +import { InteractiveERRenderer } from './InteractiveERRenderer'; + +/** + * 严格定义向导阶段序列 + * @constant STAGE_ORDER + * @description 用于实现状态单向增长逻辑,防止后端由于网络延迟返回旧状态导致的“页面回滚”现象。 + */ +const STAGE_ORDER = [ + CreationStageEnum.INITIALIZING, + CreationStageEnum.GENERATING_SCHEMA, + CreationStageEnum.SCHEMA_GENERATED, + CreationStageEnum.GENERATING_DDL, + CreationStageEnum.DDL_GENERATED, + CreationStageEnum.EXECUTING_DDL, + CreationStageEnum.COMPLETED +]; + +/** + * 获取阶段在序列中的索引位 + * @function getStageIndex + */ +const getStageIndex = (stage: CreationStageEnum | undefined | null): number => { + if (!stage) return -1; + return STAGE_ORDER.indexOf(stage); +}; + +/** + * 项目向导组件属性定义 + * @interface ProjectWizardProps + */ +interface ProjectWizardProps { + /** 目标项目的全局唯一识别码 */ + projectId: string | number; + /** 全部阶段完成后触发的成功回调 */ + onComplete: () => void; + /** 用户中途主动关闭向导的回调 */ + onClose: () => void; + /** 只读模式标识:用于查看已完成项目的历史配置 */ + viewOnly?: boolean; + /** 动态底部渲染函数:允许向导根据当前阶段向父级模态框“投影”操作按钮 */ + renderFooter?: (footer: React.ReactNode) => void; +} + +/** + * 文本内容语义化格式化工具 + * @function formatDisplayContent + * @description + * 处理 AI 生成的原始字符串。支持: + * 1. JSON 对象的结构化缩进排版; + * 2. 字符串转义字符(\n, \")的还原,提升人类阅读体验。 + */ +const formatDisplayContent = (content: any) => { + if (!content) return ''; + let str = content; + + // 类型分支:处理原生 JSON 对象 + if (typeof content !== 'string') { + str = JSON.stringify(content, null, 2); + } else { + // 类型分支:尝试对 JSON 字符串执行二次美化 + try { + const parsed = JSON.parse(content); + str = JSON.stringify(parsed, null, 2); + } catch (e) { + // 容错:解析失败则判定为普通 DDL/SQL 文本,维持现状 + } + } + + // 后处理:将技术转义符映射为物理表现字符 + return str.replace(/\\n/g, '\n').replace(/\\"/g, '"'); +}; + +/** + * 从多维 Schema 定义对象中提取核心逻辑描述 + * @function extractSchemaText + */ +const extractSchemaText = (schemaDefinition: any): string => { + if (!schemaDefinition) return ''; + + // 策略判定:优先提取字段中包含的 schema 核心代码块 + if (typeof schemaDefinition === 'object' && schemaDefinition.schema) { + return formatDisplayContent(schemaDefinition.schema); + } + + // 兜底策略:格式化全量元数据内容 + return formatDisplayContent(schemaDefinition); +}; + +/** + * @component ProjectWizard + * @description + * 向导主组件实现。通过 useEffect 驱动复杂的轮询逻辑与动画逻辑。 + */ +export const ProjectWizard: React.FC = ({ projectId, onComplete, onClose, viewOnly = false, renderFooter }) => { + // --- 核心业务状态 --- + const [project, setProject] = useState(null); + const [error, setError] = useState(null); + + // --- 编辑态缓冲区 (Draft Buffer) --- + /** 存储用户在 Schema 编辑区输入的文本,支持在确认前进行多轮微调 */ + const [editedSchema, setEditedSchema] = useState(''); + /** 存储 AI 生成的 DDL 语句快照,作为最终部署前的审查底稿 */ + const [editedDDL, setEditedDDL] = useState(''); + /** 存储向导执行过程中的额外增量需求 */ + const [requirements] = useState(''); + /** 控制完成阶段视图下的 Tab 切换(概览/Schema/DDL) */ + const [activeTab, setActiveTab] = useState('概览'); + + // --- 异步渲染状态监控 --- + /** 标识 ER 图是否正处于 AI 重新生成的处理队列中 */ + const [isRegeneratingER, setIsRegeneratingER] = useState(false); + /** 缓存本地已渲染的 ER 图代码,用于执行 DOM 局部刷新 */ + const [localERCode, setLocalERCode] = useState(''); + /** 记录上一次成功的 ER 图快照,用于检测后端推送的数据是否产生实质变化 */ + const previousERCodeRef = useRef(''); + + // --- 策略控制状态 --- + /** 控制 HTTP 轮询器的激活状态,在需要用户交互或操作已完成时进入休眠 */ + const [shouldPoll, setShouldPoll] = useState(true); + + // --- 视觉表现层状态 (UX Progress Engine) --- + /** * @description 核心 UX 设计: + * 实际的 AI 生成任务在后端执行,前端无法获取精确的百分比。 + * visualProgress 采用渐进式函数模拟“工作正在进行”的视觉反馈。 + */ + const [visualProgress, setVisualProgress] = useState(0); + + /** 持久化引用快照:用于在 Effect 销毁时将当前进度离线保存 */ + const progressSnapshotRef = useRef<{ stage: CreationStageEnum | null; progress: number }>({ + stage: null, + progress: 0, + }); + /** 恢复引用快照:记录从 localStorage 加载回来的历史进度 */ + const restoredSnapshotRef = useRef<{ stage: CreationStageEnum | null; progress: number } | null>(null); + + /** * @description 状态防护锁: + * 存储最近一次感知到的阶段索引,防止轮询请求由于延迟导致的“状态倒流”。 + */ + const latestStageRef = useRef(-1); + + /** + * 业务轮询调度 Effect + * 负责监控后端阶段转换,驱动向导界面的自动步进。 + */ + useEffect(() => { + let intervalId: NodeJS.Timeout | null = null; + + const fetchProject = async () => { + try { + const data = await getProjectDetail(projectId); + + /** + * 逻辑守卫:检测状态回退。 + * 如果当前获取的阶段在 STAGE_ORDER 中低于最新已确认阶段,则判定为过期请求并丢弃。 + */ + const currentStageIndex = getStageIndex(data.creation_stage); + if (currentStageIndex < latestStageRef.current) { + return; + } + + // 更新最新阶段索引记录 + latestStageRef.current = currentStageIndex; + setProject(data); + + /** + * 步骤:状态缓冲区初始化。 + * 在数据到达且本地缓冲区为空时,将后端数据填充至编辑器。 + */ + if (!editedSchema && data.schema_definition) { + setEditedSchema(extractSchemaText(data.schema_definition)); + } + if (!editedDDL && data.ddl_statement) { + setEditedDDL(formatDisplayContent(data.ddl_statement)); + } + + /** + * 步骤:轮询状态机控制。 + * 判定当前阶段是否需要暂停轮询。 + * 情况 1:需要用户手动点击确认(SCHEMA_GENERATED/DDL_GENERATED); + * 情况 2:项目已完全激活(ACTIVE)。 + */ + const stage = data.creation_stage; + if (!isRegeneratingER && ( + (stage === CreationStageEnum.SCHEMA_GENERATED && !!data.er_diagram_code) || + stage === CreationStageEnum.DDL_GENERATED || + stage === CreationStageEnum.COMPLETED || + data.project_status === ProjectStatusEnum.ACTIVE)) { + setShouldPoll(false); + } + } catch (err) { + console.error("Failed to fetch project:", err); + setError("无法加载项目详情。"); + } + }; + + fetchProject(); + + // 执行异步轮询逻辑 + if (!viewOnly && shouldPoll) { + // 策略分支:常规任务 3s 一次,高频图形重绘 5s 一次以减轻服务器压力 + const pollInterval = isRegeneratingER ? 5000 : 3000; + intervalId = setInterval(fetchProject, pollInterval); + } + + // 清理逻辑:防止内存泄漏 + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [projectId, viewOnly, shouldPoll, isRegeneratingER]); + + /** 状态快照实时同步 Effect */ + useEffect(() => { + progressSnapshotRef.current = { + stage: (project?.creation_stage ?? null) as CreationStageEnum | null, + progress: visualProgress, + }; + }, [project?.creation_stage, visualProgress]); + + /** * 离线进度恢复 Effect + * 实现“刷新不丢失进度”的 UX 承诺。 + */ + useEffect(() => { + if (viewOnly) return; + + const storageKey = `projectWizard.visualProgress.${projectId}`; + try { + const raw = localStorage.getItem(storageKey); + if (raw) { + const parsed = JSON.parse(raw) as { stage?: string; progress?: number }; + const stage = (parsed.stage ?? null) as CreationStageEnum | null; + const progress = Number(parsed.progress); + + // 校验历史数据的有效性 + if (Number.isFinite(progress) && progress > 0) { + const clamped = Math.max(0, Math.min(100, progress)); + restoredSnapshotRef.current = { stage, progress: clamped }; + setVisualProgress(clamped); + } + } + } catch { + // 静默处理存储异常 + } + + // 销毁前执行序列化存储 + return () => { + try { + const snapshot = progressSnapshotRef.current; + if (!snapshot.stage) return; + localStorage.setItem(storageKey, JSON.stringify({ stage: snapshot.stage, progress: snapshot.progress })); + } catch { + // ignore + } + }; + }, [projectId, viewOnly]); + + /** + * 视觉进度模拟算法引擎 (Visual Progress Engine) + * @description + * 该算法基于 Asymptotic Growth (渐近增长) 逻辑: + * 进度越接近目标阈值,单次增长步长越小。 + * 公式定义为:$$ \Delta P = \max(0.1, (P_{target} - P_{current}) \times 0.05) $$ + */ + useEffect(() => { + if (!project || viewOnly) return; + + const stage = project.creation_stage || CreationStageEnum.INITIALIZING; + let target = 0; + + // 分支判定:根据当前物理阶段设置视觉目标阈值 + switch (stage) { + case CreationStageEnum.INITIALIZING: + case CreationStageEnum.GENERATING_SCHEMA: + target = 90; // 在后端生成期间,最高停留在 90% + break; + case CreationStageEnum.SCHEMA_GENERATED: + target = 100; // 后端反馈生成完毕,视觉立即拉满 + break; + case CreationStageEnum.GENERATING_DDL: + target = 90; + break; + case CreationStageEnum.DDL_GENERATED: + target = 100; + break; + case CreationStageEnum.EXECUTING_DDL: + target = 95; // 物理执行阶段,由于涉及网络 IO 波动,最大设定为 95% + break; + case CreationStageEnum.COMPLETED: + target = 100; + break; + default: + target = 0; + } + + // 断点续传优化:若当前阶段与缓存一致,则从缓存点继续动画 + const restored = restoredSnapshotRef.current; + if (restored && restored.stage === stage && visualProgress < restored.progress) { + setVisualProgress(restored.progress); + return; + } + + // 完成类阶段的即时响应逻辑 + const isCompletedStage = stage === CreationStageEnum.SCHEMA_GENERATED || + stage === CreationStageEnum.DDL_GENERATED || + stage === CreationStageEnum.COMPLETED; + + if (visualProgress === 0 && isCompletedStage && !restored) { + setVisualProgress(100); + return; + } + + if (visualProgress >= target) return; + + /** 核心定时器:驱动进度条平滑增长 */ + const timer = setInterval(() => { + setVisualProgress(prev => { + if (prev >= target) return prev; + const remaining = target - prev; + const increment = Math.max(0.1, remaining * 0.05); + return Math.min(target, prev + increment); + }); + }, 100); + + return () => clearInterval(timer); + }, [project?.creation_stage, viewOnly]); + + /** 进度重置策略 Effect */ + useEffect(() => { + const currentStage = project?.creation_stage || CreationStageEnum.INITIALIZING; + + // 当跨入新的“生成类”阶段时,重置物理进度条 + if (currentStage === CreationStageEnum.GENERATING_SCHEMA || + currentStage === CreationStageEnum.GENERATING_DDL || + currentStage === CreationStageEnum.EXECUTING_DDL) { + const restored = restoredSnapshotRef.current; + if (restored && restored.stage === currentStage && restored.progress > 0) return; + setVisualProgress(0); + } + + // 流程完结,清理存储占用的资源 + if (currentStage === CreationStageEnum.COMPLETED) { + restoredSnapshotRef.current = null; + if (!viewOnly) { + try { + localStorage.removeItem(`projectWizard.visualProgress.${projectId}`); + } catch { + // ignore + } + } + } + }, [project?.creation_stage]); + + /** + * 业务动作:确认并提交 Schema + * 触发从逻辑架构到 DDL 物理翻译的跃迁。 + */ + const handleConfirmSchema = useCallback(async () => { + if (!project) return; + try { + restoredSnapshotRef.current = null; + setShouldPoll(true); + + /** + * 乐观更新 (Optimistic UI Update): + * 在网络请求完成前,先行将 UI 切换至下一阶段的生成状态。 + */ + const nextStage = CreationStageEnum.GENERATING_DDL; + latestStageRef.current = getStageIndex(nextStage); + + setProject(prev => prev ? { ...prev, creation_stage: nextStage } : null); + setVisualProgress(0); + + // 发起异步业务请求 + await generateDDL(project.project_id, editedSchema, requirements); + } catch (err) { + console.error("Failed to confirm schema:", err); + setError("确认 Schema 失败。"); + } + }, [project, editedSchema, requirements]); + + /** + * 业务动作:确认 DDL 并发起部署 + * 对应 SF1 自动化部署的核心触发点。 + */ + const handleConfirmDDL = useCallback(async () => { + if (!project) return; + try { + restoredSnapshotRef.current = null; + + // 关键步骤:在部署这一敏感事务期间暂停背景轮询,防止乐观状态被服务器旧缓存覆盖 + setShouldPoll(false); + + const nextStage = CreationStageEnum.EXECUTING_DDL; + latestStageRef.current = getStageIndex(nextStage); + + setProject(prev => prev ? { ...prev, creation_stage: nextStage } : null); + setVisualProgress(0); + + // 执行物理部署 + const updatedProject = await deployProject(project.project_id, editedDDL); + + if (updatedProject) { + const newStageIndex = getStageIndex(updatedProject.creation_stage); + if (newStageIndex >= latestStageRef.current) { + latestStageRef.current = newStageIndex; + setProject(updatedProject); + } + } + + setShouldPoll(true); + } catch (err) { + console.error("Failed to deploy project:", err); + setError("部署项目失败。"); + setShouldPoll(true); + } + }, [project, editedDDL]); + + /** + * 业务动作:重新生成 ER 图可视化 + * 对应 SF5 可视化更新逻辑。 + */ + const handleRegenerateER = useCallback(async () => { + if (!project || !editedSchema.trim()) return; + + // 保存前一个状态的副本 + previousERCodeRef.current = localERCode; + + setIsRegeneratingER(true); + setError(null); + setShouldPoll(true); + + try { + await regenerateER(project.project_id, editedSchema); + } catch (err) { + console.error("Failed to regenerate ER:", err); + setError("重新生成 ER 图失败。"); + setIsRegeneratingER(false); + } + }, [project, editedSchema, localERCode]); + + /** + * ER 图数据感知 Effect + * 监听后端推送的可视化 DSL 变化,并自动解除生成状态锁。 + */ + useEffect(() => { + const newERCode = project?.er_diagram_code; + if (!newERCode || newERCode === localERCode) return; + + // 若新代码已抵达且与旧代码不符,则判定重绘任务完成 + if (isRegeneratingER && newERCode !== previousERCodeRef.current) { + setIsRegeneratingER(false); + setShouldPoll(false); + } + + setLocalERCode(newERCode); + previousERCodeRef.current = newERCode; + }, [project?.er_diagram_code]); + + /** + * 底部操作栏渲染 Effect + * @description 采用了“向下投影”模式: + * 本组件将当前上下文的操作按钮实时回传给父级容器(通常是全局 Modal)。 + */ + useEffect(() => { + if (!renderFooter || !project) { + renderFooter?.(null); + return; + } + + const stage = viewOnly ? CreationStageEnum.COMPLETED : (project.creation_stage || CreationStageEnum.INITIALIZING); + + switch (stage) { + case CreationStageEnum.SCHEMA_GENERATED: + // 场景 A:Schema 已生成,提供修改与确认两个维度的动作 + renderFooter( +
+ +
+ + +
+
+ ); + break; + case CreationStageEnum.DDL_GENERATED: + // 场景 B:DDL 已准备就绪,仅提供最终部署确认 + renderFooter( + <> + + + + ); + break; + case CreationStageEnum.COMPLETED: + // 场景 C:流程完结,提供应用入口 + if (!viewOnly) { + renderFooter( + + ); + } else { + renderFooter(null); + } + break; + default: + renderFooter(null); + break; + } + }, [project?.creation_stage, viewOnly, isRegeneratingER, handleRegenerateER, handleConfirmSchema, handleConfirmDDL]); + + // --- 加载守卫 --- + if (!project) return
; + + /** + * 渲染动态阶段内容 + * 根据当前向导状态返回特定的视图模块。 + */ + const renderStageContent = () => { + const stage = viewOnly ? CreationStageEnum.COMPLETED : (project.creation_stage || CreationStageEnum.INITIALIZING); + + switch (stage) { + /** 生成中:显示全局动画与虚拟进度 */ + case CreationStageEnum.INITIALIZING: + case CreationStageEnum.GENERATING_SCHEMA: + return ( +
+ +

正在生成 Schema...

+

AI 正在分析您的需求并设计数据库 Schema。

+
+ +
+
+ ); + + /** 审计态:左右分布展示编辑器与可视化图形 */ + case CreationStageEnum.SCHEMA_GENERATED: + return ( +
+
+

+ + 审查 Schema 与 ER 图 +

+ 请审查并在需要时修改生成的 Schema,点击"修改"更新 ER 图。 +
+ + {/* 编辑与预览分屏布局 */} +
+ {/* 源码编辑区:支持手动干预 AI 逻辑偏差 */} +
+ +
+
+ + + {/* 删除公告确认对话框 */} + setIsDeleteConfirmOpen(false)} + onConfirm={confirmDelete} + title="删除公告" + message="确定要删除这条公告吗?删除后无法恢复。" + confirmText="删除" + cancelText="取消" + isDangerous={true} + showWarningIcon={true} + /> +
+ ); +}; \ No newline at end of file diff --git a/src/frontend/src/pages/AdminStatus.tsx b/src/frontend/src/pages/AdminStatus.tsx new file mode 100644 index 0000000..666b715 --- /dev/null +++ b/src/frontend/src/pages/AdminStatus.tsx @@ -0,0 +1,229 @@ +import React, { useState, useEffect } from 'react'; +import { Card, Tag } from '../components/UI.tsx'; +import { Pagination } from '../components/Pagination.tsx'; +import { LayoutDashboard, Users, AlertTriangle, Zap, ShieldAlert, Folder } from 'lucide-react'; +import { AdminStats, AdminListItem, ViolationLogListItem } from '../types.ts'; +import { adminApi } from '../api/admin.ts'; + +/** + * 管理员系统监控面板 + * * 严格对接真实 API: Dashboard Stats, Admin List, Violations + * * 移除所有无后端接口支持的图表和模拟数据 + */ +export const AdminStatus: React.FC = () => { + // --- 状态管理 --- + const [stats, setStats] = useState(null); + const [admins, setAdmins] = useState([]); + const [violations, setViolations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // 分页状态 + const [violationPage, setViolationPage] = useState(1); + const [violationTotal, setViolationTotal] = useState(0); + const violationPageSize = 10; + + // --- 数据获取 --- + useEffect(() => { + const fetchAllData = async () => { + setIsLoading(true); + try { + // 并发请求 Dashboard Stats 和 Admin List + const [statsRes, adminsRes] = await Promise.all([ + adminApi.getDashboardStats(), + adminApi.getAdmins(1, 100), // 获取更多在线管理员 + ]); + + setStats(statsRes); + setAdmins(adminsRes.items); + } catch (error) { + console.error("Failed to load admin status data", error); + } finally { + setIsLoading(false); + } + }; + + fetchAllData(); + }, []); + + // 独立获取违规日志以支持分页 + useEffect(() => { + const fetchViolations = async () => { + try { + const res = await adminApi.getViolations(violationPage, violationPageSize); + setViolations(res.items); + setViolationTotal(res.total); + } catch (error) { + console.error("Failed to load violations", error); + } + }; + fetchViolations(); + }, [violationPage]); + + return ( +
+
+

+
+ +
+ 系统状态 +

+
+ 数据最后更新: {new Date().toLocaleTimeString()} +
+
+ + {/* 1. 核心指标卡片 (Real API Data: GET /dashboard/stats) */} +
+ +
+ + 今日活跃用户 +
+
{stats?.active_users_today ?? 0}
+

Active Users Today

+
+ + +
+ + 今日查询量 +
+
{stats?.query_count_today ?? 0}
+

Queries Processed

+
+ + +
+ + 项目总数 +
+
{stats?.total_projects ?? 0}
+

Total Projects

+
+
+ + {/* 2. 数据列表区域 */} +
+ {/* 违规审计日志 (Real API Data: GET /violations) */} +
+ +
+ + + + + + + + + + + {violations.map(log => ( + + + + + + + ))} + {violations.length === 0 && ( + + )} + +
风险等级违规类型用户时间
+
+ {log.risk_level === 'CRITICAL' || log.risk_level === 'HIGH' ? ( + + ) : ( + + )} + + {log.risk_level} + +
+
+
+ + {log.event_type === 'excessive_api_usage' && 'API频率超限'} + {log.event_type === 'sql_injection_attempt' && 'SQL注入尝试'} + {log.event_type === 'suspicious_query' && '可疑查询'} + {log.event_type === 'unauthorized_access_attempt' && '未授权访问'} + {log.event_type === 'ai_violation_content' && 'AI内容违规'} + {log.event_type === 'frequent_remote_login' && 'IP频繁变更'} + {log.event_type === 'multiple_failed_logins' && '多次登录失败'} + {!['excessive_api_usage', 'sql_injection_attempt', 'suspicious_query', 'unauthorized_access_attempt', 'ai_violation_content', 'frequent_remote_login', 'multiple_failed_logins'].includes(log.event_type) && log.event_type} + +
+
+ {log.username} (ID:{log.user_id}) + + {new Date(log.created_at).toLocaleString()} +
暂无违规记录
+
+ {/* 分页器 */} + {violationTotal > 0 && ( +
+ +
+ )} +
+
+ + {/* 管理员列表 (Real API Data: GET /admins) */} +
+ +
+ {admins.map(admin => ( +
+
+
+ {admin.avatar_url ? ( + {admin.username} + ) : ( +
+ {admin.username.charAt(0).toUpperCase()} +
+ )} +
+
+
+
{admin.username}
+
{admin.email}
+
+
+
+ + {admin.is_online ? '在线' : '离线'} + +
+ ID: {admin.user_id} +
+
+
+ ))} + {admins.length === 0 && ( +
暂无管理员信息
+ )} +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/frontend/src/pages/Announcements.tsx b/src/frontend/src/pages/Announcements.tsx new file mode 100644 index 0000000..abf847b --- /dev/null +++ b/src/frontend/src/pages/Announcements.tsx @@ -0,0 +1,146 @@ +import React, { useState, useEffect } from 'react'; +import { Announcement } from '../types.ts'; +import { Card, Button, Modal, Tag } from '../components/UI.tsx'; +import { Pagination } from '../components/Pagination.tsx'; +import { Megaphone, Bell, Calendar, Loader2 } from 'lucide-react'; +import { announcementApi } from '../api/announcement.ts'; + +// 分页配置 +const PAGE_SIZE = 8; + +/** + * 用户端系统公告页面 + * 用于展示管理员发布的系统维护通知和功能更新。 + */ +export const Announcements: React.FC = () => { + // --- 状态管理 --- + const [announcements, setAnnouncements] = useState([]); + const [selected, setSelected] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // 分页状态 + const [currentPage, setCurrentPage] = useState(1); + const totalAnnouncements = announcements.length; + const paginatedAnnouncements = announcements.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE + ); + + // --- 数据获取 --- + const fetchPublishedAnnouncements = async () => { + setIsLoading(true); + try { + // 用户端仅展示 published 状态的公告 + const res = await announcementApi.getList(1, 50, 'published'); + // 按时间倒序排列 + const sorted = res.items.sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + setAnnouncements(sorted); + } catch (error) { + console.error("Failed to fetch announcements", error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchPublishedAnnouncements(); + }, []); + + return ( +
+ {/* 页面头部:标题与图标 - 固定在顶部 */} +
+
+ +
+
+

系统公告

+

查看最新的系统维护通知与功能更新

+
+
+ + {/* 公告列表区域 - 可滚动 */} +
+ {isLoading ? ( +
+ +
+ ) : ( +
+ {paginatedAnnouncements.map(item => ( + +
setSelected(item)} className="p-1"> +
+
+ {/* 标题行:包含红点指示器、标题和 NEW 标签 */} +
+ +

{item.title}

+
+ + {/* 内容摘要:限制显示 2 行 */} +

{item.content}

+ + {/* 底部元数据:发布日期 */} +
+ 发布于 {new Date(item.created_at).toLocaleDateString()} +
+
+
+
+
+ ))} + + {/* 空状态展示 */} + {announcements.length === 0 && ( +
+ +

暂无已发布的系统公告

+
+ )} +
+ )} +
+ + {/* 分页控件 - 固定在底部,始终显示 */} + {totalAnnouncements > 0 && ( +
+
+ +
+
+ )} + + {/* 公告详情模态框 */} + setSelected(null)} + title="公告详情" + maxWidth="max-w-5xl" + footer={} + > + {selected && ( +
+

{selected.title}

+
+ {new Date(selected.created_at).toLocaleString()} +
+ {/* 这里的 whitespace-pre-wrap 保证了公告内容的换行符能被正确渲染 */} +
+ {selected.content} +
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/frontend/src/pages/Dashboard.tsx b/src/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..9778886 --- /dev/null +++ b/src/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,411 @@ +import React, { useState, useEffect } from 'react'; +import { ProjectDTO, fetchProjects, createProject, updateProject, confirmDeleteProject, deleteProject, getProjectDetail } from '../api/project'; +import { ProjectStatusEnum } from '../types'; +import { Button, Modal, Input, message } from '../components/UI'; +import { Plus, PlayCircle, Sparkles, AlertTriangle, LayoutDashboard, Loader2 } from 'lucide-react'; +import { ProjectWizard } from '../components/ProjectWizard'; +import { ProjectOverview } from '../project-overview-optimization/ProjectOverview'; +import { ProjectData } from '../types/project-overview'; +import DatabaseIcon from '../project-overview-optimization/DatabaseIcon'; + +// 数据转换函数:将 ProjectDTO 转换为 ProjectData +const convertProjectDTOToProjectData = (dto: ProjectDTO): ProjectData => { + // 状态映射 + let projectStatus: 'initializing' | 'active' | 'inactive'; + switch (dto.project_status) { + case ProjectStatusEnum.INITIALIZING: + projectStatus = 'initializing'; + break; + case ProjectStatusEnum.ACTIVE: + projectStatus = 'active'; + break; + default: + projectStatus = 'inactive'; + break; + } + + return { + project_id: dto.project_id, + project_name: dto.project_name, + description: dto.description, + project_status: projectStatus, + updated_at: dto.updated_at || dto.created_at, + db_type: dto.db_type + }; +}; + +interface DashboardProps { + onProjectSelect?: (project: ProjectDTO) => void; +} + +export const Dashboard: React.FC = ({ onProjectSelect }) => { + const [projects, setProjects] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 部署状态管理 + const [isDeploying, setIsDeploying] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [currentProjectId, setCurrentProjectId] = useState(null); + const [wizardFooter, setWizardFooter] = useState(null); + + // 创建表单状态 + const [newProjectName, setNewProjectName] = useState(''); + const [newProjectType, setNewProjectType] = useState<'MySQL' | 'PostgreSQL' | 'SQLite'>('MySQL'); + const [newProjectDesc, setNewProjectDesc] = useState(''); + + // --- 项目管理状态 (编辑/删除) --- + const [projectToDelete, setProjectToDelete] = useState(null); + const [deleteConfirmation, setDeleteConfirmation] = useState(''); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const [projectToEdit, setProjectToEdit] = useState(null); + const [editName, setEditName] = useState(''); + const [editDesc, setEditDesc] = useState(''); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + // 1. 初始化加载 + const loadProjects = async () => { + setLoading(true); + setError(null); + try { + const data = await fetchProjects(); + setProjects(data.items); + } catch (error) { + console.error("Failed to load projects:", error); + setError("项目列表加载失败,请稍后重试。"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadProjects(); + }, []); + + // 创建项目 (POST) + const handleCreateProject = async () => { + if (!newProjectName.trim()) { + message.error('请输入项目名称'); + return; + } + if (!newProjectDesc.trim()) { + message.error('请输入业务场景描述'); + return; + } + if (isCreating) return; + + setIsCreating(true); + setIsDeploying(true); + + try { + const res = await createProject({ + name: newProjectName, + type: newProjectType, + description: newProjectDesc + }); + setCurrentProjectId(res.project_id); + } catch (e) { + console.error("Failed to create project:", e); + setIsDeploying(false); + setIsCreating(false); + setCurrentProjectId(null); + message.error("创建失败,请检查网络或重试"); + } + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + setTimeout(() => { + setNewProjectName(''); + setNewProjectDesc(''); + setIsDeploying(false); + setIsCreating(false); + setCurrentProjectId(null); + setWizardFooter(null); + }, 300); + loadProjects(); + }; + + const handleWizardComplete = async () => { + if (currentProjectId) { + try { + // 获取最新项目详情以确保状态正确 + const project = await getProjectDetail(currentProjectId); + + // 关闭模态框并重置状态 + setIsModalOpen(false); + setTimeout(() => { + setNewProjectName(''); + setNewProjectDesc(''); + setIsDeploying(false); + setIsCreating(false); + setCurrentProjectId(null); + setWizardFooter(null); + }, 300); + + // 刷新列表 + loadProjects(); + + // 跳转到工作台 + if (onProjectSelect) { + onProjectSelect(project); + } + } catch (e) { + console.error("Failed to navigate to workspace:", e); + handleCloseModal(); + } + } else { + handleCloseModal(); + } + }; + + // --- 项目管理操作 --- + const handleCardClick = (projectId: number) => { + const project = projects.find(p => p.project_id === projectId); + if (project) { + if (project.project_status === ProjectStatusEnum.ACTIVE) { + onProjectSelect?.(project); + } else { + // Resume deployment + setCurrentProjectId(project.project_id); + setIsDeploying(true); + setIsModalOpen(true); + } + } + }; + + const handleDeleteClick = (projectId: number) => { + const project = projects.find(p => p.project_id === projectId); + if (project) { + setProjectToDelete(project); + setDeleteConfirmation(''); + setIsDeleteModalOpen(true); + } + }; + + const handleConfirmDelete = async () => { + if (!projectToDelete) return; + if (!deleteConfirmation.trim()) { + message.error('请输入DELETE以确认删除'); + return; + } + try { + const { confirmation_token } = await confirmDeleteProject(projectToDelete.project_id, deleteConfirmation); + await deleteProject(projectToDelete.project_id, confirmation_token); + setIsDeleteModalOpen(false); + setProjectToDelete(null); + loadProjects(); + } catch (err) { + console.error("Delete failed", err); + message.error("删除失败,请确认输入的验证信息正确"); + } + }; + + const handleEditClick = (projectId: number) => { + const project = projects.find(p => p.project_id === projectId); + if (project) { + setProjectToEdit(project); + setEditName(project.project_name); + setEditDesc(project.description); + setIsEditModalOpen(true); + } + }; + + const handleSaveEdit = async () => { + if (!projectToEdit) return; + if (!editName.trim()) { + message.error('请输入项目名称'); + return; + } + if (!editDesc.trim()) { + message.error('请输入项目描述'); + return; + } + try { + await updateProject(projectToEdit.project_id, { + project_name: editName, + description: editDesc + }); + setIsEditModalOpen(false); + setProjectToEdit(null); + loadProjects(); + } catch (err) { + console.error("Update failed", err); + message.error("更新项目信息失败"); + } + }; + + const showProgressView = isDeploying && !!currentProjectId; + + return ( + // 优化响应式间距和布局 +
+ {/* 项目概览标题区域 - 使用与系统公告一致的样式 */} +
+
+ +
+
+

项目概览

+

管理您的 AI 驱动数据库实例

+
+
+ +
+
+ + {/* 使用优化后的项目概览组件 */} +
+ +
+ + + + + + ) : wizardFooter + } + > + {!showProgressView ? ( +
+
+
+ +
+
+

AI 智能部署

+

基于大模型。只需用自然语言描述业务场景,系统将自动完成数据库部署。

+
+
+
+ setNewProjectName(e.target.value)} required /> +
+ +
+ {/* 更新:增加 SQLite 选项 */} + {(['MySQL', 'PostgreSQL', 'SQLite'] as const).map(type => { + // 将显示名称映射为 DatabaseIcon 需要的 dbType + const dbTypeMap = { + MySQL: 'mysql', + PostgreSQL: 'postgresql', + SQLite: 'sqlite' + } as const; + return ( +
setNewProjectType(type)} className={`cursor-pointer px-3 sm:px-4 py-2.5 sm:py-3 rounded-lg border flex items-center gap-2 sm:gap-3 transition-all ${newProjectType === type ? 'border-primary bg-blue-50 text-primary ring-1 ring-primary' : 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'}`}> + + {type} +
+ ); + })} +
+
+
+ + +
+
+
+ ) : ( + + )} +
+ + {/* 删除确认模态框 */} + setIsDeleteModalOpen(false)} + title="确认删除项目" + maxWidth="max-w-md" + footer={ + <> + + + + } + > +
+
+ +
+

高风险操作

+

此操作将永久删除项目 {projectToDelete?.project_name} 及其所有关联的数据(表结构、数据、Schema)。此操作无法撤销!

+
+
+
+ + setDeleteConfirmation(e.target.value)} + placeholder="" + className="border-red-300 focus:border-red-500 focus:ring-red-100" + /> +
+
+
+ + {/* 编辑项目模态框 */} + setIsEditModalOpen(false)} + title="编辑项目信息" + maxWidth="max-w-md" + footer={ + <> + + + + } + > +
+ setEditName(e.target.value)} + placeholder="项目名称" + required + /> +
+ + +
+
+
+ +
+ ); +}; \ No newline at end of file diff --git a/src/frontend/src/pages/DatabaseViewer.tsx b/src/frontend/src/pages/DatabaseViewer.tsx new file mode 100644 index 0000000..a20ed21 --- /dev/null +++ b/src/frontend/src/pages/DatabaseViewer.tsx @@ -0,0 +1,417 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + RefreshCw, + Database, + Table, + ChevronRight, + ChevronDown, + HardDrive, + ChevronLeft +} from 'lucide-react'; +import { + fetchDatabaseStructure, + fetchTableData, + fetchTableSchema, + fetchDatabaseStructureByProject, + fetchTableDataByProject, + fetchTableSchemaByProject, + TableInfo, + ColumnInfo +} from '../api/database'; + +interface DatabaseViewerProps { + sessionId?: number; + projectId?: number; + className?: string; +} + +const DatabaseViewer: React.FC = ({ sessionId, projectId, className }) => { + const [tables, setTables] = useState([]); + const [selectedTable, setSelectedTable] = useState(null); + const [columns, setColumns] = useState([]); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({ + database: true, + tables: true + }); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [isManuallyCollapsed, setIsManuallyCollapsed] = useState(false); + const [isManuallyExpanded, setIsManuallyExpanded] = useState(false); + + const rootRef = useRef(null); + const [explorerWidth, setExplorerWidth] = useState(256); + const explorerWidthRef = useRef(256); + const pendingExplorerWidthRef = useRef(256); + const resizeStartXRef = useRef(0); + const resizeStartWidthRef = useRef(256); + const resizeRafIdRef = useRef(null); + const [isResizingExplorer, setIsResizingExplorer] = useState(false); + const resizeObserverRef = useRef(null); + + useEffect(() => { + try { + const raw = localStorage.getItem('databaseViewer.explorerWidth'); + const parsed = raw ? Number(raw) : NaN; + if (Number.isFinite(parsed)) { + const clamped = Math.max(200, Math.min(520, parsed)); + setExplorerWidth(clamped); + } + } catch { + // ignore + } + }, []); + + useEffect(() => { + explorerWidthRef.current = explorerWidth; + pendingExplorerWidthRef.current = explorerWidth; + rootRef.current?.style.setProperty('--dbv-explorer-width', `${explorerWidth}px`); + try { + localStorage.setItem('databaseViewer.explorerWidth', String(explorerWidth)); + } catch { + // ignore + } + }, [explorerWidth]); + + // ResizeObserver 监听 DataViewer 宽度变化,自动折叠侧边栏 + useEffect(() => { + const rootElement = rootRef.current; + if (!rootElement) return; + + // 创建 ResizeObserver 监听容器宽度变化 + resizeObserverRef.current = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width } = entry.contentRect; + + // 当 DataViewer 宽度 < 400px 时自动折叠侧边栏,但不覆盖手动展开操作 + if (width < 400 && !isSidebarCollapsed && !isManuallyExpanded) { + setIsSidebarCollapsed(true); + // 自动折叠时不设置手动折叠标记 + } + // 当宽度 >= 500px 时,只有在非手动折叠的情况下才自动展开 + else if (width >= 500 && isSidebarCollapsed && !isManuallyCollapsed) { + setIsSidebarCollapsed(false); + setIsManuallyExpanded(false); // 清除手动展开标记 + } + } + }); + + resizeObserverRef.current.observe(rootElement); + + return () => { + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + resizeObserverRef.current = null; + } + }; + }, [isSidebarCollapsed, isManuallyCollapsed, isManuallyExpanded]); // 添加 isManuallyExpanded 依赖 + + useEffect(() => { + if (!isResizingExplorer) return; + + const prevUserSelect = document.body.style.userSelect; + const prevCursor = document.body.style.cursor; + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + const applyWidth = (width: number) => { + rootRef.current?.style.setProperty('--dbv-explorer-width', `${width}px`); + }; + + const handleMouseMove = (e: MouseEvent) => { + const dx = e.clientX - resizeStartXRef.current; + const nextWidth = resizeStartWidthRef.current + dx; + const clamped = Math.max(200, Math.min(520, nextWidth)); + pendingExplorerWidthRef.current = clamped; + + if (resizeRafIdRef.current != null) return; + resizeRafIdRef.current = window.requestAnimationFrame(() => { + resizeRafIdRef.current = null; + applyWidth(pendingExplorerWidthRef.current); + }); + }; + + const handleMouseUp = () => { + if (resizeRafIdRef.current != null) { + window.cancelAnimationFrame(resizeRafIdRef.current); + resizeRafIdRef.current = null; + } + + document.body.style.userSelect = prevUserSelect; + document.body.style.cursor = prevCursor; + setExplorerWidth(pendingExplorerWidthRef.current); + setIsResizingExplorer(false); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + if (resizeRafIdRef.current != null) { + window.cancelAnimationFrame(resizeRafIdRef.current); + resizeRafIdRef.current = null; + } + document.body.style.userSelect = prevUserSelect; + document.body.style.cursor = prevCursor; + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizingExplorer]); + + useEffect(() => { + loadTables(); + }, [sessionId, projectId]); + + const loadTables = async () => { + try { + setLoading(true); + let result: TableInfo[]; + if (projectId) { + result = await fetchDatabaseStructureByProject(projectId); + } else if (sessionId) { + result = await fetchDatabaseStructure(sessionId); + } else { + result = []; + } + setTables(result); + } catch (error) { + console.error('Failed to load tables:', error); + } finally { + setLoading(false); + } + }; + + const handleTableSelect = async (tableName: string) => { + try { + setLoading(true); + setSelectedTable(tableName); + // Clear previous data to avoid showing stale data if fetch fails or returns empty + setColumns([]); + setData([]); + + let schemaData: ColumnInfo[]; + let tableData: any[]; + + if (projectId) { + [schemaData, tableData] = await Promise.all([ + fetchTableSchemaByProject(projectId, tableName), + fetchTableDataByProject(projectId, tableName) + ]); + } else if (sessionId) { + [schemaData, tableData] = await Promise.all([ + fetchTableSchema(sessionId, tableName), + fetchTableData(sessionId, tableName) + ]); + } else { + schemaData = []; + tableData = []; + } + + setColumns(schemaData); + setData(tableData); + } catch (error) { + console.error('Failed to load table data:', error); + } finally { + setLoading(false); + } + }; + + const toggleExpand = (key: string) => { + setExpanded(prev => ({ ...prev, [key]: !prev[key] })); + }; + + const handleRefresh = () => { + if (selectedTable) { + handleTableSelect(selectedTable); + } else { + loadTables(); + } + }; + + return ( +
+ {/* Sidebar */} +
+
+ {!isSidebarCollapsed && ( +
+ 资源管理器 +
+ )} + {!isSidebarCollapsed && ( + + )} + {isSidebarCollapsed && ( + + )} +
+ +
+ {!isSidebarCollapsed ? ( +
+ {/* Database Node */} +
toggleExpand('database')} + > + {expanded.database ? : } + + 数据库 +
+ + {expanded.database && ( +
+ {/* Tables Node */} +
toggleExpand('tables')} + > + {expanded.tables ? : } + + + + + {expanded.tables && ( +
+ {tables.map((table) => ( +
handleTableSelect(table.name)} + > +
+ {table.name} + + ))} + + )} + + )} + + ) : null} + + + + {/* 拖拽条:调整 Explorer 宽度 */} + {!isSidebarCollapsed && ( +
{ + resizeStartXRef.current = e.clientX; + resizeStartWidthRef.current = explorerWidthRef.current; + setIsResizingExplorer(true); + }} + onDoubleClick={() => setExplorerWidth(256)} + title="拖拽调整 Explorer 宽度(双击重置)" + /> + )} + + {/* Main Pane */} +
+ {/* Header */} +
+
+

+ {selectedTable || '选择一个表'} +

+
+ +
+ +
+
+ + {/* Content */} +
+ {selectedTable ? ( +
+ {/* 确保表格容器有正确的水平滚动 */} +
+
+ {/* 确保列头 sticky 定位正常工作 */} + + + {columns.map((col) => ( + + ))} + + + + {data.map((row, rowIndex) => ( + + {columns.map((col) => ( + + ))} + + ))} + {data.length === 0 && !loading && ( + + + + )} + +
+
+ {col.field} + {col.type} +
+
+ {row[col.field]?.toString() ?? null} +
+ 暂无数据 +
+
+
+ ) : ( +
+ +

选择一个表

+
+ )} +
+
+
+ ); +}; + +export default DatabaseViewer; \ No newline at end of file diff --git a/src/frontend/src/pages/Glossary.tsx b/src/frontend/src/pages/Glossary.tsx new file mode 100644 index 0000000..cdc3b3e --- /dev/null +++ b/src/frontend/src/pages/Glossary.tsx @@ -0,0 +1,602 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Plus, + Search, + Trash2, + Download, + Upload, + Edit2, + Book, + CheckSquare, + Square, + Filter, + Loader2, + FileText, + AlertCircle, + CheckCircle2, + Database +} from 'lucide-react'; +import * as XLSX from 'xlsx'; +import { KnowledgeTerm, KnowledgeImportResponse, Project } from '../types.ts'; +import { glossaryApi, CreateTermParams } from '../api/glossary.ts'; +import { fetchProjects, ProjectDTO } from '../api/project.ts'; +import { Button, Input, Modal, Card, message, ConfirmDialog, Select } from '../components/UI.tsx'; +import { Pagination } from '../components/Pagination.tsx'; + +interface GlossaryProps { + /** 当前工作区选中的项目(从 App 传入) */ + selectedProject?: Project | null; +} + +export const Glossary: React.FC = ({ selectedProject }) => { + // --- 状态管理 --- + + // 项目列表 (从真实 API 获取) + const [projectList, setProjectList] = useState([]); + const [loadingProjects, setLoadingProjects] = useState(false); + + // 当前选中的项目 ID + const [selectedProjectId, setSelectedProjectId] = useState(''); + + // 术语数据列表 + const [terms, setTerms] = useState([]); + const [total, setTotal] = useState(0); + const [loadingTerms, setLoadingTerms] = useState(false); + + // 筛选与分页 + const [searchQuery, setSearchQuery] = useState(""); + const [page, setPage] = useState(1); + const pageSize = 9; // 每页 9 条 + const [hasMore, setHasMore] = useState(false); + + // 选中项 (用于批量删除) + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // 确认删除对话框状态 + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + + // 模态框与表单 + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingTerm, setEditingTerm] = useState(null); + const [formData, setFormData] = useState({ + term: "", + definition: "", + examples: "" + }); + const [isSaving, setIsSaving] = useState(false); + + // 文件导入相关状态 + const fileInputRef = useRef(null); + const [isImporting, setIsImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + const [isImportResultModalOpen, setIsImportResultModalOpen] = useState(false); + + // --- 初始化: 获取真实项目列表 --- + useEffect(() => { + const loadProjects = async () => { + setLoadingProjects(true); + try { + const data = await fetchProjects(); + setProjectList(data.items); + // 优先使用工作区选中的项目,否则使用列表第一个 + if (data.items.length > 0 && !selectedProjectId) { + if (selectedProject?.id) { + // 如果有工作区选中的项目,使用该项目 + setSelectedProjectId(selectedProject.id); + } else { + // 否则使用列表第一个 + setSelectedProjectId(String(data.items[0].project_id)); + } + } + } catch (error) { + console.error("Failed to fetch projects", error); + } finally { + setLoadingProjects(false); + } + }; + loadProjects(); + }, [selectedProject]); + + // --- 获取术语列表 (依赖 selectedProjectId) --- + useEffect(() => { + if (selectedProjectId) { + fetchTerms(); + setSelectedIds(new Set()); // 切换项目时清空选中 + } else { + setTerms([]); // 无项目时清空列表 + } + }, [selectedProjectId, page, searchQuery]); + + const fetchTerms = async () => { + if (!selectedProjectId) return; + setLoadingTerms(true); + try { + const res = await glossaryApi.getList(selectedProjectId, page, pageSize, searchQuery || undefined); + setTerms(res.items); + setTotal(res.total); + setHasMore(page * pageSize < res.total); + } catch (error) { + console.error("Failed to fetch terms", error); + } finally { + setLoadingTerms(false); + } + }; + + // --- 交互处理 --- + + const handleInputChange = (e: React.ChangeEvent, field: keyof CreateTermParams) => { + setFormData(prev => ({ ...prev, [field]: e.target.value })); + }; + + const openModal = (termToEdit?: KnowledgeTerm) => { + if (termToEdit) { + setEditingTerm(termToEdit); + setFormData({ + term: termToEdit.term, + definition: termToEdit.definition, + examples: termToEdit.examples || "" + }); + } else { + setEditingTerm(null); + setFormData({ term: "", definition: "", examples: "" }); + } + setIsModalOpen(true); + }; + + const handleSave = async () => { + if (!selectedProjectId) { + message.error("请先选择一个项目"); + return; + } + if (!formData.term.trim() || !formData.definition.trim()) { + message.error("术语名称和定义不能为空"); + return; + } + + setIsSaving(true); + try { + if (editingTerm) { + // 更新 + await glossaryApi.update(selectedProjectId, editingTerm.knowledge_id, formData); + } else { + // 创建 + await glossaryApi.create(selectedProjectId, formData); + } + setIsModalOpen(false); + fetchTerms(); // 刷新列表 + } catch (error: any) { + // 错误已由响应拦截器自动处理并显示,这里只需记录日志 + console.error("Save failed", error); + } finally { + setIsSaving(false); + } + }; + + const handleBatchDelete = async () => { + if (selectedIds.size === 0) return; + setIsDeleteConfirmOpen(true); + }; + + const confirmBatchDelete = async () => { + try { + const idsArray = Array.from(selectedIds); + const res = await glossaryApi.deleteBatch(selectedProjectId, idsArray); + + message.success(`删除成功。成功: ${res.imported_count || selectedIds.size}, 失败: ${res.failed_count || 0}`); + setSelectedIds(new Set()); + fetchTerms(); + } catch (error: any) { + message.error("删除失败"); + } + }; + + const toggleSelection = (id: number) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + if (selectedIds.size === terms.length && terms.length > 0) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(terms.map(t => t.knowledge_id))); + } + }; + + // --- 导入导出 --- + + const handleExport = () => { + if (!selectedProjectId) return; + + // 根据选中状态决定导出哪些数据 + const termsToExport = selectedIds.size > 0 + ? terms.filter(t => selectedIds.has(t.knowledge_id)) + : terms; + + if (termsToExport.length === 0) { + message.warning("没有可导出的术语数据"); + return; + } + + // 转换数据格式 + const exportData = termsToExport.map((term: KnowledgeTerm) => ({ + '术语': term.term, + '定义': term.definition, + '示例': term.examples || '', + '创建时间': term.created_at ? new Date(term.created_at).toLocaleDateString('zh-CN') : '' + })); + + // 创建工作簿和工作表 + const worksheet = XLSX.utils.json_to_sheet(exportData); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, '业务术语'); + + // 设置列宽 + worksheet['!cols'] = [ + { wch: 20 }, // 术语 + { wch: 50 }, // 定义 + { wch: 30 }, // 示例 + { wch: 15 } // 创建时间 + ]; + + // 生成文件名 + const projectName = projectList.find(p => String(p.project_id) === selectedProjectId)?.project_name || 'project'; + const timestamp = new Date().toISOString().slice(0, 10); + const filename = `业务术语_${projectName}_${timestamp}.xlsx`; + + // 下载文件 + XLSX.writeFile(workbook, filename); + message.success(`成功导出 ${termsToExport.length} 条术语`); + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const fileName = file.name.toLowerCase(); + const validExtensions = ['.csv', '.xlsx', '.xls']; + if (!validExtensions.some(ext => fileName.endsWith(ext))) { + message.error("文件格式不支持:请上传 Excel (.xlsx/.xls) 或 CSV 文件"); + if (fileInputRef.current) fileInputRef.current.value = ''; + return; + } + + if (!selectedProjectId) { + message.error("请先选择项目"); + return; + } + + setIsImporting(true); + try { + const res = await glossaryApi.importTerms(selectedProjectId, file); + // 设置导入结果并打开结果模态框,替代 alert + setImportResult(res); + setIsImportResultModalOpen(true); + + if (res.imported_count > 0) { + fetchTerms(); + } + } catch (error: any) { + // 错误已由响应拦截器自动处理并显示,这里只需记录日志 + console.error("Import failed", error); + } finally { + setIsImporting(false); + if (fileInputRef.current) fileInputRef.current.value = ''; // 重置 input + } + }; + + // --- 渲染 --- + + if (loadingProjects && projectList.length === 0) { + return ( +
+ + 加载项目列表... +
+ ); + } + + if (projectList.length === 0) { + return
您暂无数据库项目,请先在项目概览中创建。
; + } + + const totalPages = Math.ceil(total / pageSize); + + return ( +
+ {/* 头部与操作栏 - 固定在顶部 */} +
+ {/* 业务术语标题区域 - 使用与系统公告一致的样式 */} +
+
+ +
+
+

业务术语

+

管理项目相关的业务术语定义

+
+
+ {/* 项目选择器 */} +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && setPage(1)} + /> +
+ +
+ {selectedIds.size > 0 && ( + + )} +
+ + + + + +
+
+
+ + {/* 列表内容区 - 可滚动 */} +
+ {loadingTerms && terms.length === 0 ? ( +
+ +
+ ) : terms.length === 0 ? ( +
+ +

暂无业务术语

+

当前项目下还没有定义任何业务术语。

+
+ ) : ( +
+ {/* 全选控制 */} +
+ + 共 {total} 条记录 +
+ + {terms.map((term) => ( + +
+
+ +
+
+
+
+

+ {term.term} +

+

+ 定义 + {term.definition} +

+ {term.examples && ( +
+ +
+ 示例: + {term.examples} +
+
+ )} +
+ +
+ +
+
+
+
+
+ ))} +
+ )} +
+ + {/* 分页控制栏 - 固定在底部,始终显示 */} + {total > 0 && ( +
+
+ +
+
+ )} + + {/* 1. 新增/编辑 模态框 */} + setIsModalOpen(false)} + title={editingTerm ? "编辑业务术语" : "新增业务术语"} + footer={ + <> + + + + } + > +
+ handleInputChange(e, 'term')} + placeholder="例如:GMV" + required + /> +
+ +