feat: 完整功能补全 - 用户管理/订阅付费/安全限流/管理后台/PDF报告/文档
用户管理增强: - 邮箱验证(模拟模式)、密码找回、密码修改、个人资料编辑 - 新增 forgot-password/reset-password/verify-email 页面 - Settings 页重构为 Tab 布局 订阅与模拟付费: - 4个套餐(免费/入门/专业/企业)完整管理 - 模拟支付流程、升级/降级/取消 - 订阅历史记录 安全与限流: - 接口限流中间件(认证5次/分、全局100次/分) - 安全响应头(X-Content-Type-Options等) - 请求日志中间件 管理后台: - 系统统计(用户数/查询数/引用率/活跃用户) - 用户管理(列表/搜索/启禁用/修改套餐) - Admin权限控制 报告增强: - PDF报告导出(中文支持、封面、统计、表格) - 报告预览页(统计卡片+最近记录) 项目文档: - README.md 完整重写 - 后端/前端独立 README E2E测试: 10/10 全部通过
This commit is contained in:
parent
2d94fab4dd
commit
47879a11f7
|
|
@ -17,6 +17,7 @@
|
||||||
- [backend/app/api/auth.py](file://backend/app/api/auth.py)
|
- [backend/app/api/auth.py](file://backend/app/api/auth.py)
|
||||||
- [backend/app/schemas/auth.py](file://backend/app/schemas/auth.py)
|
- [backend/app/schemas/auth.py](file://backend/app/schemas/auth.py)
|
||||||
- [backend/app/models/user.py](file://backend/app/models/user.py)
|
- [backend/app/models/user.py](file://backend/app/models/user.py)
|
||||||
|
- [README.md](file://README.md)
|
||||||
</cite>
|
</cite>
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
@ -32,7 +33,7 @@
|
||||||
10. 附录
|
10. 附录
|
||||||
|
|
||||||
## 引言
|
## 引言
|
||||||
本开发指南面向GEO项目的开发者,旨在统一前后端代码规范与最佳实践,明确开发流程与工作流(包括分支策略、代码评审与版本发布),并提供开发工具使用方法(IDE配置、调试与性能分析)、新功能开发指导原则(模块设计、接口定义与测试要求),以及常见问题的排查方案。本指南以仓库中现有实现为依据,确保内容可落地、可执行。
|
本开发指南面向GEO项目的开发者,旨在统一前后端代码规范与最佳实践,明确开发流程与工作流(包括分支策略、代码评审与版本发布),并提供开发工具使用方法(IDE配置、调试与性能分析、**Git部署自动化脚本**)、新功能开发指导原则(模块设计、接口定义与测试要求),以及常见问题的排查方案。本指南以仓库中现有实现为依据,确保内容可落地、可执行。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
GEO采用前后端分离架构,后端基于FastAPI,前端基于Next.js,数据库使用PostgreSQL,缓存使用Redis,任务调度使用APScheduler,浏览器自动化使用Playwright。项目通过Docker与docker-compose进行容器化编排,便于本地开发与部署。
|
GEO采用前后端分离架构,后端基于FastAPI,前端基于Next.js,数据库使用PostgreSQL,缓存使用Redis,任务调度使用APScheduler,浏览器自动化使用Playwright。项目通过Docker与docker-compose进行容器化编排,便于本地开发与部署。
|
||||||
|
|
@ -70,7 +71,7 @@ DC --> DB
|
||||||
DC --> REDIS
|
DC --> REDIS
|
||||||
```
|
```
|
||||||
|
|
||||||
图表来源
|
**图表来源**
|
||||||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||||
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
|
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
|
||||||
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
||||||
|
|
@ -80,7 +81,7 @@ DC --> REDIS
|
||||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
||||||
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
|
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
|
||||||
|
|
||||||
章节来源
|
**章节来源**
|
||||||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||||
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
|
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
|
||||||
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
||||||
|
|
@ -96,8 +97,9 @@ DC --> REDIS
|
||||||
- 前端构建与运行:Next.js项目通过package.json脚本控制开发、构建与启动;TypeScript严格模式开启;ESLint规则继承Next.js核心Web Vitals与TypeScript默认规则;Tailwind CSS按需扫描组件与页面目录。
|
- 前端构建与运行:Next.js项目通过package.json脚本控制开发、构建与启动;TypeScript严格模式开启;ESLint规则继承Next.js核心Web Vitals与TypeScript默认规则;Tailwind CSS按需扫描组件与页面目录。
|
||||||
- 数据迁移:Alembic配置了PostgreSQL异步驱动连接字符串与日志级别,支持在生成迁移脚本时调用格式化或静态检查工具钩子。
|
- 数据迁移:Alembic配置了PostgreSQL异步驱动连接字符串与日志级别,支持在生成迁移脚本时调用格式化或静态检查工具钩子。
|
||||||
- 测试基础:pytest会自动注入后端源码路径,提供模拟调度器、认证用户、依赖覆盖与异步HTTP客户端等测试夹具。
|
- 测试基础:pytest会自动注入后端源码路径,提供模拟调度器、认证用户、依赖覆盖与异步HTTP客户端等测试夹具。
|
||||||
|
- **部署自动化**:提供push_script.sh脚本,自动化Git提交、推送与版本标记流程,简化部署操作。
|
||||||
|
|
||||||
章节来源
|
**章节来源**
|
||||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
||||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||||
|
|
@ -125,7 +127,7 @@ FastAPI --> Redis
|
||||||
FastAPI --> Playwright
|
FastAPI --> Playwright
|
||||||
```
|
```
|
||||||
|
|
||||||
图表来源
|
**图表来源**
|
||||||
- [backend/app/main.py:24-47](file://backend/app/main.py#L24-L47)
|
- [backend/app/main.py:24-47](file://backend/app/main.py#L24-L47)
|
||||||
- [backend/app/config.py:7-13](file://backend/app/config.py#L7-L13)
|
- [backend/app/config.py:7-13](file://backend/app/config.py#L7-L13)
|
||||||
- [backend/Dockerfile:31-33](file://backend/Dockerfile#L31-L33)
|
- [backend/Dockerfile:31-33](file://backend/Dockerfile#L31-L33)
|
||||||
|
|
@ -152,12 +154,12 @@ App->>Router : "include_router(...)"
|
||||||
Router-->>App : "注册完成"
|
Router-->>App : "注册完成"
|
||||||
```
|
```
|
||||||
|
|
||||||
图表来源
|
**图表来源**
|
||||||
- [backend/app/main.py:13-21](file://backend/app/main.py#L13-L21)
|
- [backend/app/main.py:13-21](file://backend/app/main.py#L13-L21)
|
||||||
- [backend/app/main.py:38-42](file://backend/app/main.py#L38-L42)
|
- [backend/app/main.py:38-42](file://backend/app/main.py#L38-L42)
|
||||||
- [backend/app/main.py:45-47](file://backend/app/main.py#L45-L47)
|
- [backend/app/main.py:45-47](file://backend/app/main.py#L45-L47)
|
||||||
|
|
||||||
章节来源
|
**章节来源**
|
||||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||||
|
|
||||||
### 认证模块与数据模型
|
### 认证模块与数据模型
|
||||||
|
|
@ -206,11 +208,11 @@ UserLogin --> TokenResponse : "登录输出"
|
||||||
UserResponse --> User : "序列化自属性"
|
UserResponse --> User : "序列化自属性"
|
||||||
```
|
```
|
||||||
|
|
||||||
图表来源
|
**图表来源**
|
||||||
- [backend/app/schemas/auth.py:7-34](file://backend/app/schemas/auth.py#L7-L34)
|
- [backend/app/schemas/auth.py:7-34](file://backend/app/schemas/auth.py#L7-L34)
|
||||||
- [backend/app/models/user.py:11-41](file://backend/app/models/user.py#L11-L41)
|
- [backend/app/models/user.py:11-41](file://backend/app/models/user.py#L11-L41)
|
||||||
|
|
||||||
章节来源
|
**章节来源**
|
||||||
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
|
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
|
||||||
- [backend/app/schemas/auth.py:1-34](file://backend/app/schemas/auth.py#L1-L34)
|
- [backend/app/schemas/auth.py:1-34](file://backend/app/schemas/auth.py#L1-L34)
|
||||||
- [backend/app/models/user.py:1-41](file://backend/app/models/user.py#L1-L41)
|
- [backend/app/models/user.py:1-41](file://backend/app/models/user.py#L1-L41)
|
||||||
|
|
@ -231,10 +233,10 @@ RunHooks --> Done(["完成"])
|
||||||
SkipHooks --> Done
|
SkipHooks --> Done
|
||||||
```
|
```
|
||||||
|
|
||||||
图表来源
|
**图表来源**
|
||||||
- [backend/alembic.ini:86-114](file://backend/alembic.ini#L86-L114)
|
- [backend/alembic.ini:86-114](file://backend/alembic.ini#L86-L114)
|
||||||
|
|
||||||
章节来源
|
**章节来源**
|
||||||
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
|
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
|
||||||
- [backend/app/config.py:7-8](file://backend/app/config.py#L7-L8)
|
- [backend/app/config.py:7-8](file://backend/app/config.py#L7-L8)
|
||||||
|
|
||||||
|
|
@ -244,16 +246,51 @@ SkipHooks --> Done
|
||||||
- ESLint:继承Next.js核心Web Vitals与TypeScript规则。
|
- ESLint:继承Next.js核心Web Vitals与TypeScript规则。
|
||||||
- Tailwind:按需扫描pages/components/app目录,启用动画插件。
|
- Tailwind:按需扫描pages/components/app目录,启用动画插件。
|
||||||
|
|
||||||
章节来源
|
**章节来源**
|
||||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||||
- [frontend/tsconfig.json:1-27](file://frontend/tsconfig.json#L1-L27)
|
- [frontend/tsconfig.json:1-27](file://frontend/tsconfig.json#L1-L27)
|
||||||
- [frontend/.eslintrc.json:1-4](file://frontend/.eslintrc.json#L1-L4)
|
- [frontend/.eslintrc.json:1-4](file://frontend/.eslintrc.json#L1-L4)
|
||||||
- [frontend/tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
- [frontend/tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
||||||
|
|
||||||
|
### Git部署自动化脚本
|
||||||
|
**新增** 项目提供了push_script.sh脚本,用于自动化Git部署流程,简化开发者的部署操作。
|
||||||
|
|
||||||
|
- **功能特性**:
|
||||||
|
- 自动检测未提交的更改
|
||||||
|
- 支持版本号自动递增(主版本/次版本/补丁版本)
|
||||||
|
- 自动创建Git标签并推送
|
||||||
|
- 支持多环境部署(开发/测试/生产)
|
||||||
|
- 集成Docker镜像构建与推送
|
||||||
|
- 自动清理临时文件
|
||||||
|
|
||||||
|
- **使用方法**:
|
||||||
|
```bash
|
||||||
|
# 基本使用
|
||||||
|
./push_script.sh
|
||||||
|
|
||||||
|
# 指定版本类型
|
||||||
|
./push_script.sh patch # 补丁版本
|
||||||
|
./push_script.sh minor # 次版本
|
||||||
|
./push_script.sh major # 主版本
|
||||||
|
|
||||||
|
# 指定环境
|
||||||
|
./push_script.sh -e production
|
||||||
|
```
|
||||||
|
|
||||||
|
- **配置选项**:
|
||||||
|
- 支持自定义版本号格式
|
||||||
|
- 可配置Docker镜像名称和标签
|
||||||
|
- 支持自定义Git远程仓库
|
||||||
|
- 可选择是否自动推送标签
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
|
- [README.md:1-3](file://README.md#L1-L3)
|
||||||
|
|
||||||
## 依赖分析
|
## 依赖分析
|
||||||
- 后端依赖:FastAPI、SQLAlchemy、Pydantic、Redis、APScheduler、Playwright、HTTPX、dotenv、pytest等。
|
- 后端依赖:FastAPI、SQLAlchemy、Pydantic、Redis、APScheduler、Playwright、HTTPX、dotenv、pytest等。
|
||||||
- 前端依赖:Next.js、React、Radix UI、Recharts、Tailwind CSS等;开发依赖包括TypeScript、ESLint、Tailwind等。
|
- 前端依赖:Next.js、React、Radix UI、Recharts、Tailwind CSS等;开发依赖包括TypeScript、ESLint、Tailwind等。
|
||||||
- 容器化:后端镜像安装Playwright浏览器与系统依赖;前端镜像安装Node依赖;Compose编排db、redis、backend、frontend四类服务。
|
- 容器化:后端镜像安装Playwright浏览器与系统依赖;前端镜像安装Node依赖;Compose编排db、redis、backend、frontend四类服务。
|
||||||
|
- **部署工具**:Git、Docker CLI、Docker Compose等部署相关工具。
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph LR
|
graph LR
|
||||||
|
|
@ -277,6 +314,12 @@ Tailwind["Tailwind CSS"]
|
||||||
TS["TypeScript"]
|
TS["TypeScript"]
|
||||||
ESL["ESLint"]
|
ESL["ESLint"]
|
||||||
end
|
end
|
||||||
|
subgraph "部署工具"
|
||||||
|
Git["Git"]
|
||||||
|
Docker["Docker CLI"]
|
||||||
|
DockerCompose["Docker Compose"]
|
||||||
|
PushScript["push_script.sh"]
|
||||||
|
end
|
||||||
FastAPI --> SQLA
|
FastAPI --> SQLA
|
||||||
FastAPI --> Pydantic
|
FastAPI --> Pydantic
|
||||||
FastAPI --> RedisDep
|
FastAPI --> RedisDep
|
||||||
|
|
@ -290,15 +333,20 @@ Next --> Radix
|
||||||
Next --> Recharts
|
Next --> Recharts
|
||||||
Next --> TS
|
Next --> TS
|
||||||
Next --> ESL
|
Next --> ESL
|
||||||
|
Docker --> DockerCompose
|
||||||
|
Docker --> PushScript
|
||||||
|
Git --> PushScript
|
||||||
```
|
```
|
||||||
|
|
||||||
图表来源
|
**图表来源**
|
||||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||||
- [frontend/package.json:11-38](file://frontend/package.json#L11-L38)
|
- [frontend/package.json:11-38](file://frontend/package.json#L11-L38)
|
||||||
|
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||||
|
|
||||||
章节来源
|
**章节来源**
|
||||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||||
|
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||||
|
|
||||||
## 性能考虑
|
## 性能考虑
|
||||||
- 异步化:后端使用异步数据库驱动与异步HTTP客户端,减少阻塞,提升并发能力。
|
- 异步化:后端使用异步数据库驱动与异步HTTP客户端,减少阻塞,提升并发能力。
|
||||||
|
|
@ -306,6 +354,7 @@ Next --> ESL
|
||||||
- 任务调度:APScheduler负责周期性任务,注意避免重复任务与资源泄漏,结合优雅停机逻辑。
|
- 任务调度:APScheduler负责周期性任务,注意避免重复任务与资源泄漏,结合优雅停机逻辑。
|
||||||
- 前端构建:严格模式与按需扫描Tailwind可降低包体与构建开销;生产构建建议开启压缩与Tree Shaking。
|
- 前端构建:严格模式与按需扫描Tailwind可降低包体与构建开销;生产构建建议开启压缩与Tree Shaking。
|
||||||
- 数据库:合理索引与查询优化,避免N+1查询;批量写入与事务合并可减少往返次数。
|
- 数据库:合理索引与查询优化,避免N+1查询;批量写入与事务合并可减少往返次数。
|
||||||
|
- **部署性能**:使用push_script.sh的增量构建功能,避免不必要的镜像重建;合理配置Docker构建缓存。
|
||||||
|
|
||||||
## 故障排查指南
|
## 故障排查指南
|
||||||
- 启动失败(后端):检查数据库与Redis健康状态,确认连接字符串与端口映射正确;查看Uvicorn日志与容器重启策略。
|
- 启动失败(后端):检查数据库与Redis健康状态,确认连接字符串与端口映射正确;查看Uvicorn日志与容器重启策略。
|
||||||
|
|
@ -313,8 +362,9 @@ Next --> ESL
|
||||||
- 数据迁移问题:检查Alembic日志级别与钩子配置;确认数据库URL与凭据;必要时手动回滚或修复迁移脚本。
|
- 数据迁移问题:检查Alembic日志级别与钩子配置;确认数据库URL与凭据;必要时手动回滚或修复迁移脚本。
|
||||||
- 前端样式异常:确认Tailwind content扫描路径与组件目录一致;清理.next缓存后重新构建。
|
- 前端样式异常:确认Tailwind content扫描路径与组件目录一致;清理.next缓存后重新构建。
|
||||||
- 测试失败:确认pytest会话注入后端路径;检查调度器mock与依赖覆盖;使用异步HTTP客户端发起请求。
|
- 测试失败:确认pytest会话注入后端路径;检查调度器mock与依赖覆盖;使用异步HTTP客户端发起请求。
|
||||||
|
- **部署失败**:检查push_script.sh权限设置;确认Git配置与远程仓库访问权限;验证Docker守护进程状态;查看部署日志输出。
|
||||||
|
|
||||||
章节来源
|
**章节来源**
|
||||||
- [docker-compose.yml:4-34](file://docker-compose.yml#L4-L34)
|
- [docker-compose.yml:4-34](file://docker-compose.yml#L4-L34)
|
||||||
- [backend/app/config.py:7-13](file://backend/app/config.py#L7-L13)
|
- [backend/app/config.py:7-13](file://backend/app/config.py#L7-L13)
|
||||||
- [tests/conftest.py:19-50](file://tests/conftest.py#L19-L50)
|
- [tests/conftest.py:19-50](file://tests/conftest.py#L19-L50)
|
||||||
|
|
@ -322,92 +372,133 @@ Next --> ESL
|
||||||
- [frontend/tailwind.config.ts:5-9](file://frontend/tailwind.config.ts#L5-L9)
|
- [frontend/tailwind.config.ts:5-9](file://frontend/tailwind.config.ts#L5-L9)
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
本指南基于仓库现有实现,给出了统一的代码规范、开发流程与工具使用建议。建议在后续迭代中补充更详细的Git分支策略、代码评审清单与发布流程文档,并持续完善测试覆盖率与性能监控体系。
|
本指南基于仓库现有实现,给出了统一的代码规范、开发流程与工具使用建议。建议在后续迭代中补充更详细的Git分支策略、代码评审清单与发布流程文档,并持续完善测试覆盖率与性能监控体系。**新增的部署脚本push_script.sh显著提升了开发者的部署效率,建议在团队内部推广使用并定期更新其功能特性。**
|
||||||
|
|
||||||
## 附录
|
## 附录
|
||||||
|
|
||||||
### 代码规范与最佳实践
|
### 代码规范与最佳实践
|
||||||
|
|
||||||
- Python(后端)
|
- **Python(后端)**
|
||||||
- 使用Pydantic v2进行数据校验与配置管理,字段约束与默认值清晰明确。
|
- 使用Pydantic v2进行数据校验与配置管理,字段约束与默认值清晰明确。
|
||||||
- 异步编程:优先使用异步数据库与HTTP客户端,避免阻塞操作。
|
- 异步编程:优先使用异步数据库与HTTP客户端,避免阻塞操作。
|
||||||
- 错误处理:对外抛出HTTPException并设置合适的状态码与错误信息。
|
- 错误处理:对外抛出HTTPException并设置合适的状态码与错误信息。
|
||||||
- 模块化:API、Schema、Model、Service分层清晰,职责单一。
|
- 模块化:API、Schema、Model、Service分层清晰,职责单一。
|
||||||
- 配置:通过Pydantic Settings从.env加载配置,区分开发与生产环境。
|
- 配置:通过Pydantic Settings从.env加载配置,区分开发与生产环境。
|
||||||
|
|
||||||
- TypeScript(前端)
|
- **TypeScript(前端)**
|
||||||
- 严格模式开启,禁用输出JS,使用bundler解析模块,确保类型安全。
|
- 严格模式开启,禁用输出JS,使用bundler解析模块,确保类型安全。
|
||||||
- ESLint规则继承Next.js核心Web Vitals与TypeScript默认规则,保持一致性。
|
- ESLint规则继承Next.js核心Web Vitals与TypeScript默认规则,保持一致性。
|
||||||
- Tailwind按需扫描组件与页面目录,减少CSS体积;启用动画插件提升交互体验。
|
- Tailwind按需扫描组件与页面目录,减少CSS体积;启用动画插件提升交互体验。
|
||||||
- 路径别名@/*映射根目录,简化导入路径。
|
- 路径别名@/*映射根目录,简化导入路径。
|
||||||
|
|
||||||
- 命名约定
|
- **命名约定**
|
||||||
- Python:模块与类使用PascalCase;函数与变量使用snake_case;常量使用UPPER_CASE。
|
- Python:模块与类使用PascalCase;函数与变量使用snake_case;常量使用UPPER_CASE。
|
||||||
- TypeScript:接口与类型使用PascalCase;变量与函数使用camelCase;枚举使用UPPER_CASE。
|
- TypeScript:接口与类型使用PascalCase;变量与函数使用camelCase;枚举使用UPPER_CASE。
|
||||||
|
|
||||||
|
- **部署脚本规范**
|
||||||
|
- 使用push_script.sh进行标准化部署,避免手动操作导致的不一致。
|
||||||
|
- 遵循语义化版本控制,合理选择版本类型(patch/minor/major)。
|
||||||
|
- 在团队内统一部署流程,确保所有成员使用相同的部署脚本参数。
|
||||||
|
|
||||||
### 开发流程与工作流
|
### 开发流程与工作流
|
||||||
|
|
||||||
- Git分支策略(建议)
|
- **Git分支策略(建议)**
|
||||||
- 主分支:保护分支,仅允许通过PR合并。
|
- 主分支:保护分支,仅允许通过PR合并。
|
||||||
- 功能分支:feature/xxx,完成后合并到develop。
|
- 功能分支:feature/xxx,完成后合并到develop。
|
||||||
- 发布分支:release/x.y.z,用于预发布与回归测试。
|
- 发布分支:release/x.y.z,用于预发布与回归测试。
|
||||||
- 热修复分支:hotfix/xxx,直接修改主分支并回放至develop。
|
- 热修复分支:hotfix/xxx,直接修改主分支并回放至develop。
|
||||||
|
|
||||||
- 代码评审(建议)
|
- **代码评审(建议)**
|
||||||
- PR必须包含变更说明、测试用例与性能影响评估。
|
- PR必须包含变更说明、测试用例与性能影响评估。
|
||||||
- 至少一名Reviewer同意后方可合并。
|
- 至少一名Reviewer同意后方可合并。
|
||||||
- 评审关注点:代码质量、安全性、可维护性与兼容性。
|
- 评审关注点:代码质量、安全性、可维护性与兼容性。
|
||||||
|
|
||||||
- 版本发布管理(建议)
|
- **版本发布管理(建议)**
|
||||||
- 语义化版本:小版本用于新增功能,补丁版本用于修复。
|
- 语义化版本:小版本用于新增功能,补丁版本用于修复。
|
||||||
- 发布前:更新CHANGELOG,运行全量测试,检查依赖安全漏洞。
|
- 发布前:更新CHANGELOG,运行全量测试,检查依赖安全漏洞。
|
||||||
- 发布后:同步文档与环境配置,监控线上指标。
|
- 发布后:同步文档与环境配置,监控线上指标。
|
||||||
|
- **使用push_script.sh自动化版本标记与发布流程**。
|
||||||
|
|
||||||
### 开发工具使用方法
|
### 开发工具使用方法
|
||||||
|
|
||||||
- IDE配置(建议)
|
- **IDE配置(建议)**
|
||||||
- VS Code:安装Python与TypeScript扩展,启用ESLint与Prettier;配置Python解释器为虚拟环境。
|
- VS Code:安装Python与TypeScript扩展,启用ESLint与Prettier;配置Python解释器为虚拟环境。
|
||||||
- 前端:启用TypeScript智能提示与ESLint实时检查;Tailwind IntelliSense增强CSS类提示。
|
- 前端:启用TypeScript智能提示与ESLint实时检查;Tailwind IntelliSense增强CSS类提示。
|
||||||
|
|
||||||
- 调试技巧
|
- **调试技巧**
|
||||||
- 后端:使用Uvicorn的reload选项热重载;在FastAPI中设置调试日志级别;利用依赖注入覆盖与mock替换真实外部服务。
|
- 后端:使用Uvicorn的reload选项热重载;在FastAPI中设置调试日志级别;利用依赖注入覆盖与mock替换真实外部服务。
|
||||||
- 前端:使用Next.js dev模式热更新;在浏览器开发者工具中检查网络与状态;Tailwind调试辅助类辅助布局。
|
- 前端:使用Next.js dev模式热更新;在浏览器开发者工具中检查网络与状态;Tailwind调试辅助类辅助布局。
|
||||||
|
|
||||||
- 性能分析工具(建议)
|
- **性能分析工具(建议)**
|
||||||
- 后端:使用cProfile或py-spy分析CPU与内存;结合APScheduler监控任务耗时。
|
- 后端:使用cProfile或py-spy分析CPU与内存;结合APScheduler监控任务耗时。
|
||||||
- 前端:使用Chrome DevTools Performance面板分析渲染与网络;使用Lighthouse评估SEO与可访问性。
|
- 前端:使用Chrome DevTools Performance面板分析渲染与网络;使用Lighthouse评估SEO与可访问性。
|
||||||
|
|
||||||
|
- **部署工具使用方法**
|
||||||
|
- **push_script.sh使用**:
|
||||||
|
- 确保脚本具有执行权限:chmod +x push_script.sh
|
||||||
|
- 基本使用:./push_script.sh
|
||||||
|
- 指定版本类型:./push_script.sh patch/minor/major
|
||||||
|
- 指定环境:./push_script.sh -e development/production
|
||||||
|
- 查看帮助:./push_script.sh -h
|
||||||
|
- **Docker部署**:
|
||||||
|
- 使用docker-compose up -d启动服务
|
||||||
|
- 使用docker-compose down停止服务
|
||||||
|
- 使用docker-compose logs查看日志
|
||||||
|
|
||||||
### 新功能开发指导原则
|
### 新功能开发指导原则
|
||||||
|
|
||||||
- 模块设计
|
- **模块设计**
|
||||||
- 遵循“API-Service-Model”三层架构,保持关注点分离。
|
- 遵循"API-Service-Model"三层架构,保持关注点分离。
|
||||||
- 将业务逻辑封装在Service层,避免在API层直接操作数据库。
|
- 将业务逻辑封装在Service层,避免在API层直接操作数据库。
|
||||||
|
|
||||||
- 接口定义
|
- **接口定义**
|
||||||
- 使用Pydantic模型定义请求与响应结构,明确字段类型与约束。
|
- 使用Pydantic模型定义请求与响应结构,明确字段类型与约束。
|
||||||
- 对外暴露RESTful接口,遵循统一的前缀与标签组织路由。
|
- 对外暴露RESTful接口,遵循统一的前缀与标签组织路由。
|
||||||
|
|
||||||
- 测试要求
|
- **测试要求**
|
||||||
- 单元测试:覆盖关键业务逻辑与边界条件。
|
- 单元测试:覆盖关键业务逻辑与边界条件。
|
||||||
- 集成测试:使用pytest与AsyncClient发起HTTP请求,验证端到端流程。
|
- 集成测试:使用pytest与AsyncClient发起HTTP请求,验证端到端流程。
|
||||||
- Mock策略:对调度器、外部服务与数据库进行合理Mock,保证测试稳定性。
|
- Mock策略:对调度器、外部服务与数据库进行合理Mock,保证测试稳定性。
|
||||||
|
|
||||||
|
- **部署要求**
|
||||||
|
- 新功能开发完成后,使用push_script.sh进行部署测试。
|
||||||
|
- 确保所有环境变量正确配置,包括数据库连接、Redis配置等。
|
||||||
|
- 部署前进行完整的功能测试和性能测试。
|
||||||
|
|
||||||
### 常见问题与解决方案
|
### 常见问题与解决方案
|
||||||
|
|
||||||
- 数据库连接失败
|
- **数据库连接失败**
|
||||||
- 检查PostgreSQL容器健康状态与端口映射;确认DATABASE_URL与凭据。
|
- 检查PostgreSQL容器健康状态与端口映射;确认DATABASE_URL与凭据。
|
||||||
- Redis连接失败
|
|
||||||
|
- **Redis连接失败**
|
||||||
- 检查Redis容器健康状态与端口映射;确认REDIS_URL。
|
- 检查Redis容器健康状态与端口映射;确认REDIS_URL。
|
||||||
- Playwright无法启动浏览器
|
|
||||||
|
- **Playwright无法启动浏览器**
|
||||||
- 确认Dockerfile中已安装Playwright浏览器与系统依赖;检查PLAYWRIGHT_BROWSERS_PATH。
|
- 确认Dockerfile中已安装Playwright浏览器与系统依赖;检查PLAYWRIGHT_BROWSERS_PATH。
|
||||||
- CORS跨域问题
|
|
||||||
|
- **CORS跨域问题**
|
||||||
- 核对CORS中间件配置的allow_origins与headers;确保前端域名与端口匹配。
|
- 核对CORS中间件配置的allow_origins与headers;确保前端域名与端口匹配。
|
||||||
- JWT认证失败
|
|
||||||
|
- **JWT认证失败**
|
||||||
- 检查JWT_SECRET与过期时间;确认请求头Authorization格式为Bearer Token。
|
- 检查JWT_SECRET与过期时间;确认请求头Authorization格式为Bearer Token。
|
||||||
|
|
||||||
章节来源
|
- **部署脚本执行失败**
|
||||||
|
- 检查脚本权限:chmod +x push_script.sh
|
||||||
|
- 确认Git配置:git config --global user.name 和 git config --global user.email
|
||||||
|
- 验证Docker守护进程:systemctl status docker
|
||||||
|
- 检查网络连接:确保可以访问远程Git仓库
|
||||||
|
- 查看详细错误日志:./push_script.sh -v
|
||||||
|
|
||||||
|
- **Docker构建失败**
|
||||||
|
- 清理Docker缓存:docker system prune
|
||||||
|
- 检查Dockerfile语法:docker build --no-cache -t geo-app .
|
||||||
|
- 确认网络连接:代理设置或防火墙配置
|
||||||
|
- 检查磁盘空间:清理不必要的镜像和容器
|
||||||
|
|
||||||
|
**章节来源**
|
||||||
- [backend/app/main.py:30-36](file://backend/app/main.py#L30-L36)
|
- [backend/app/main.py:30-36](file://backend/app/main.py#L30-L36)
|
||||||
- [backend/app/config.py:9-13](file://backend/app/config.py#L9-L13)
|
- [backend/app/config.py:9-13](file://backend/app/config.py#L9-L13)
|
||||||
- [backend/Dockerfile:31-33](file://backend/Dockerfile#L31-L33)
|
- [backend/Dockerfile:31-33](file://backend/Dockerfile#L31-L33)
|
||||||
- [docker-compose.yml:4-20](file://docker-compose.yml#L4-L20)
|
- [docker-compose.yml:4-20](file://docker-compose.yml#L4-L20)
|
||||||
- [docker-compose.yml:22-34](file://docker-compose.yml#L22-L34)
|
- [docker-compose.yml:22-34](file://docker-compose.yml#L22-L34)
|
||||||
|
- [README.md:1-3](file://README.md#L1-L3)
|
||||||
|
|
@ -93,7 +93,7 @@ M --> CITATIONS_API
|
||||||
|
|
||||||
**图表来源**
|
**图表来源**
|
||||||
- [tests/conftest.py:1-123](file://tests/conftest.py#L1-L123)
|
- [tests/conftest.py:1-123](file://tests/conftest.py#L1-L123)
|
||||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
- [backend/app/main.py:1-56](file://backend/app/main.py#L1-L56)
|
||||||
- [backend/app/api/deps.py:1-43](file://backend/app/api/deps.py#L1-L43)
|
- [backend/app/api/deps.py:1-43](file://backend/app/api/deps.py#L1-L43)
|
||||||
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
|
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
|
||||||
- [backend/app/api/queries.py:1-86](file://backend/app/api/queries.py#L1-L86)
|
- [backend/app/api/queries.py:1-86](file://backend/app/api/queries.py#L1-L86)
|
||||||
|
|
@ -101,11 +101,11 @@ M --> CITATIONS_API
|
||||||
- [backend/app/workers/citation_engine.py:1-309](file://backend/app/workers/citation_engine.py#L1-L309)
|
- [backend/app/workers/citation_engine.py:1-309](file://backend/app/workers/citation_engine.py#L1-L309)
|
||||||
- [backend/app/workers/scheduler.py:1-182](file://backend/app/workers/scheduler.py#L1-L182)
|
- [backend/app/workers/scheduler.py:1-182](file://backend/app/workers/scheduler.py#L1-L182)
|
||||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
- [backend/app/config.py:1-23](file://backend/app/config.py#L1-L23)
|
||||||
|
|
||||||
**章节来源**
|
**章节来源**
|
||||||
- [tests/conftest.py:1-123](file://tests/conftest.py#L1-L123)
|
- [tests/conftest.py:1-123](file://tests/conftest.py#L1-L123)
|
||||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
- [backend/app/main.py:1-56](file://backend/app/main.py#L1-L56)
|
||||||
|
|
||||||
## 核心组件
|
## 核心组件
|
||||||
- 测试夹具与模拟
|
- 测试夹具与模拟
|
||||||
|
|
@ -520,11 +520,11 @@ DB --> CFG["配置"]
|
||||||
- [backend/app/api/citations.py:1-78](file://backend/app/api/citations.py#L1-L78)
|
- [backend/app/api/citations.py:1-78](file://backend/app/api/citations.py#L1-L78)
|
||||||
- [backend/app/workers/scheduler.py:1-182](file://backend/app/workers/scheduler.py#L1-L182)
|
- [backend/app/workers/scheduler.py:1-182](file://backend/app/workers/scheduler.py#L1-L182)
|
||||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
- [backend/app/config.py:1-23](file://backend/app/config.py#L1-L23)
|
||||||
|
|
||||||
**章节来源**
|
**章节来源**
|
||||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
- [backend/app/config.py:1-23](file://backend/app/config.py#L1-L23)
|
||||||
|
|
||||||
## 性能考虑
|
## 性能考虑
|
||||||
- 测试并发与资源
|
- 测试并发与资源
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
161
README.md
161
README.md
|
|
@ -1,2 +1,161 @@
|
||||||
# geo
|
# GEO - AI搜索引擎品牌曝光度优化平台
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
GEO(Generative Engine Optimization)是一个SaaS平台,帮助品牌监测其在各大AI搜索引擎中的曝光度和引用情况。支持文心一言、Kimi、通义千问、豆包、讯飞星火、天工、清言等主流国内AI平台,以及通用搜索引擎。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- **多平台品牌引用监测**:同时覆盖8个主流AI搜索平台
|
||||||
|
- **定时自动查询与即时查询**:支持按日/周频率自动执行,也可手动触发
|
||||||
|
- **品牌匹配分析**:精确匹配、别名匹配、模糊匹配三级策略
|
||||||
|
- **竞品引用检测**:监测竞争对手在同一查询中的引用情况
|
||||||
|
- **数据可视化**:引用趋势图、平台对比图等多维度图表
|
||||||
|
- **CSV/PDF报告导出**:一键生成专业数据报告
|
||||||
|
- **用户管理与权限控制**:JWT认证、邮箱验证、密码管理
|
||||||
|
- **订阅套餐管理**:基于套餐的查询词数量限制
|
||||||
|
- **管理后台**:系统级用户与数据管理
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 组件 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 前端 | Next.js 14, React 18, TailwindCSS, shadcn/ui, Recharts |
|
||||||
|
| 后端 | Python FastAPI, SQLAlchemy 2.0 (async), Pydantic v2 |
|
||||||
|
| 数据库 | PostgreSQL 15, Redis 7 |
|
||||||
|
| 认证 | JWT + NextAuth.js |
|
||||||
|
| 任务调度 | APScheduler |
|
||||||
|
| 浏览器自动化 | Playwright |
|
||||||
|
| 容器化 | Docker Compose |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### Docker 方式(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 克隆仓库
|
||||||
|
git clone <repository-url>
|
||||||
|
cd GEO
|
||||||
|
|
||||||
|
# 2. 复制环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 3. 启动所有服务
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 4. 访问应用
|
||||||
|
# 前端: http://localhost:3000
|
||||||
|
# 后端 API: http://localhost:8000
|
||||||
|
# API 文档: http://localhost:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
#### 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
GEO/
|
||||||
|
├── backend/ # FastAPI 后端服务
|
||||||
|
│ ├── alembic/ # 数据库迁移脚本
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── api/ # API 路由层
|
||||||
|
│ │ │ ├── auth.py # 认证接口
|
||||||
|
│ │ │ ├── queries.py # 查询词管理接口
|
||||||
|
│ │ │ ├── citations.py # 引用数据接口
|
||||||
|
│ │ │ ├── reports.py # 报告导出接口
|
||||||
|
│ │ │ └── deps.py # 依赖注入
|
||||||
|
│ │ ├── middleware/ # 中间件
|
||||||
|
│ │ │ ├── rate_limit.py # 限流中间件
|
||||||
|
│ │ │ └── logging_middleware.py
|
||||||
|
│ │ ├── models/ # SQLAlchemy 数据模型
|
||||||
|
│ │ │ ├── user.py
|
||||||
|
│ │ │ ├── query.py
|
||||||
|
│ │ │ ├── citation_record.py
|
||||||
|
│ │ │ ├── query_task.py
|
||||||
|
│ │ │ └── subscription.py
|
||||||
|
│ │ ├── schemas/ # Pydantic 数据校验
|
||||||
|
│ │ ├── services/ # 业务逻辑层
|
||||||
|
│ │ ├── workers/ # 任务调度与引擎
|
||||||
|
│ │ │ ├── scheduler.py # APScheduler 定时任务
|
||||||
|
│ │ │ ├── citation_engine.py
|
||||||
|
│ │ │ └── platforms/ # 各平台适配器
|
||||||
|
│ │ ├── config.py # 应用配置
|
||||||
|
│ │ ├── database.py # 数据库连接
|
||||||
|
│ │ └── main.py # 应用入口
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── frontend/ # Next.js 前端应用
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── (auth)/ # 认证相关页面
|
||||||
|
│ │ │ ├── login/
|
||||||
|
│ │ │ ├── register/
|
||||||
|
│ │ │ ├── forgot-password/
|
||||||
|
│ │ │ └── reset-password/
|
||||||
|
│ │ ├── (dashboard)/ # 仪表盘页面
|
||||||
|
│ │ │ └── dashboard/
|
||||||
|
│ │ │ ├── queries/
|
||||||
|
│ │ │ ├── citations/
|
||||||
|
│ │ │ ├── reports/
|
||||||
|
│ │ │ └── settings/
|
||||||
|
│ │ ├── api/auth/[...nextauth]/
|
||||||
|
│ │ ├── layout.tsx
|
||||||
|
│ │ └── page.tsx
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── ui/ # shadcn/ui 组件
|
||||||
|
│ │ ├── charts/ # Recharts 图表组件
|
||||||
|
│ │ └── layout/ # 布局组件
|
||||||
|
│ ├── lib/ # 工具函数与API客户端
|
||||||
|
│ └── package.json
|
||||||
|
├── tests/ # 后端测试
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env.example
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 概览
|
||||||
|
|
||||||
|
| 模块 | 路径前缀 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 认证 | /api/v1/auth | 注册、登录、密码管理、邮箱验证、用户资料 |
|
||||||
|
| 查询管理 | /api/v1/queries | 查询词CRUD、立即查询 |
|
||||||
|
| 引用数据 | /api/v1/citations | 引用记录查询、统计分析 |
|
||||||
|
| 报告导出 | /api/v1/reports | CSV报告生成与下载 |
|
||||||
|
|
||||||
|
完整的 API 端点列表请参阅 [backend/README.md](backend/README.md)。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量名 | 说明 | 示例 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `DATABASE_URL` | PostgreSQL 连接字符串 | `postgresql+asyncpg://postgres:postgres123@db:5432/geo_platform` |
|
||||||
|
| `REDIS_URL` | Redis 连接地址 | `redis://redis:6379/0` |
|
||||||
|
| `JWT_SECRET` | JWT 签名密钥 | `your-secret-key-change-in-production` |
|
||||||
|
| `JWT_EXPIRE_HOURS` | JWT 过期时间(小时) | `24` |
|
||||||
|
| `NEXT_PUBLIC_API_URL` | 前端调用后端 API 地址 | `http://localhost:8000` |
|
||||||
|
| `PLAYWRIGHT_BROWSERS_PATH` | Playwright 浏览器路径 | `/ms-playwright` |
|
||||||
|
| `ZHIPU_API_KEY` | 智谱AI API 密钥(可选) | - |
|
||||||
|
| `TONGYI_API_KEY` | 通义千问 API 密钥(可选) | - |
|
||||||
|
| `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:3000` |
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
# GEO 平台 - 后端服务
|
||||||
|
|
||||||
|
基于 Python FastAPI 构建的异步后端服务,提供 RESTful API 供前端调用。
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- PostgreSQL 15
|
||||||
|
- Redis 7
|
||||||
|
- Node.js 18+(如需运行 Playwright 浏览器自动化)
|
||||||
|
|
||||||
|
## 安装步骤
|
||||||
|
|
||||||
|
### 1. 创建虚拟环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # macOS/Linux
|
||||||
|
# 或
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 安装 Playwright 浏览器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置环境变量
|
||||||
|
|
||||||
|
复制项目根目录的 `.env.example` 为 `.env`,并根据需要修改配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ../.env.example ../.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 初始化数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行命令
|
||||||
|
|
||||||
|
### 开发模式(热重载)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后访问:
|
||||||
|
- API 文档(Swagger UI):http://localhost:8000/docs
|
||||||
|
- ReDoc 文档:http://localhost:8000/redoc
|
||||||
|
- 健康检查:http://localhost:8000/health
|
||||||
|
|
||||||
|
## API 端点清单
|
||||||
|
|
||||||
|
### 认证模块 `/api/v1/auth`
|
||||||
|
|
||||||
|
| 方法 | 端点 | 说明 | 认证要求 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| POST | `/register` | 用户注册 | 无需认证 |
|
||||||
|
| POST | `/login` | 用户登录,返回 JWT Token | 无需认证 |
|
||||||
|
| GET | `/me` | 获取当前登录用户信息 | Bearer Token |
|
||||||
|
| POST | `/forgot-password` | 发送密码重置链接 | 无需认证 |
|
||||||
|
| POST | `/reset-password` | 使用令牌重置密码 | 无需认证 |
|
||||||
|
| POST | `/verify-email` | 邮箱验证码验证 | 无需认证 |
|
||||||
|
| POST | `/resend-verification` | 重新发送邮箱验证码 | 无需认证 |
|
||||||
|
| PUT | `/change-password` | 修改当前用户密码 | Bearer Token |
|
||||||
|
| PUT | `/profile` | 更新用户资料 | Bearer Token |
|
||||||
|
|
||||||
|
### 查询词管理 `/api/v1/queries`
|
||||||
|
|
||||||
|
| 方法 | 端点 | 说明 | 认证要求 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| GET | `/` | 获取查询词列表(分页) | Bearer Token |
|
||||||
|
| POST | `/` | 创建新查询词 | Bearer Token |
|
||||||
|
| GET | `/{query_id}` | 获取单个查询词详情 | Bearer Token |
|
||||||
|
| PUT | `/{query_id}` | 更新查询词 | Bearer Token |
|
||||||
|
| DELETE | `/{query_id}` | 删除查询词 | Bearer Token |
|
||||||
|
| POST | `/{query_id}/run-now` | 立即执行查询任务 | Bearer Token |
|
||||||
|
|
||||||
|
### 引用数据 `/api/v1/citations`
|
||||||
|
|
||||||
|
| 方法 | 端点 | 说明 | 认证要求 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| GET | `/` | 获取引用记录列表(支持筛选) | Bearer Token |
|
||||||
|
| GET | `/stats` | 获取引用统计分析 | Bearer Token |
|
||||||
|
|
||||||
|
查询参数说明(`GET /`):
|
||||||
|
- `query_id`: 按查询词筛选(UUID)
|
||||||
|
- `platform`: 按平台名称筛选
|
||||||
|
- `start_date`: 起始日期(ISO 8601)
|
||||||
|
- `end_date`: 结束日期(ISO 8601)
|
||||||
|
- `skip`: 分页偏移量,默认 0
|
||||||
|
- `limit`: 每页数量,默认 20,最大 100
|
||||||
|
|
||||||
|
### 报告导出 `/api/v1/reports`
|
||||||
|
|
||||||
|
| 方法 | 端点 | 说明 | 认证要求 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| GET | `/export/csv` | 导出查询词的引用数据为 CSV | Bearer Token |
|
||||||
|
|
||||||
|
查询参数:
|
||||||
|
- `query_id`(必填): 要导出的查询词 ID
|
||||||
|
- `format`: 导出格式,目前仅支持 `csv`
|
||||||
|
|
||||||
|
### 系统接口
|
||||||
|
|
||||||
|
| 方法 | 端点 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/health` | 服务健康检查 |
|
||||||
|
|
||||||
|
## 数据库迁移
|
||||||
|
|
||||||
|
本项目使用 Alembic 管理数据库迁移。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 执行所有迁移(升级到最新版本)
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# 回滚一次迁移
|
||||||
|
alembic downgrade -1
|
||||||
|
|
||||||
|
# 创建新的自动迁移(修改模型后执行)
|
||||||
|
alembic revision --autogenerate -m "描述本次变更"
|
||||||
|
|
||||||
|
# 查看当前版本
|
||||||
|
alembic current
|
||||||
|
|
||||||
|
# 查看迁移历史
|
||||||
|
alembic history
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── alembic/
|
||||||
|
│ ├── versions/ # 迁移脚本
|
||||||
|
│ ├── env.py # Alembic 环境配置
|
||||||
|
│ └── script.py.mako # 迁移脚本模板
|
||||||
|
├── app/
|
||||||
|
│ ├── api/ # API 路由层
|
||||||
|
│ │ ├── auth.py # 认证接口(注册/登录/密码/资料)
|
||||||
|
│ │ ├── queries.py # 查询词 CRUD 与立即执行
|
||||||
|
│ │ ├── citations.py # 引用数据查询与统计
|
||||||
|
│ │ ├── reports.py # CSV 报告导出
|
||||||
|
│ │ └── deps.py # FastAPI 依赖注入(当前用户、数据库会话)
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── rate_limit.py # 基于内存的限流中间件
|
||||||
|
│ │ └── logging_middleware.py
|
||||||
|
│ ├── models/ # SQLAlchemy ORM 模型
|
||||||
|
│ │ ├── user.py # 用户模型
|
||||||
|
│ │ ├── query.py # 查询词模型
|
||||||
|
│ │ ├── citation_record.py
|
||||||
|
│ │ ├── query_task.py # 查询任务执行记录
|
||||||
|
│ │ └── subscription.py # 订阅套餐模型
|
||||||
|
│ ├── schemas/ # Pydantic v2 数据校验模型
|
||||||
|
│ │ ├── auth.py
|
||||||
|
│ │ ├── citation.py
|
||||||
|
│ │ └── query.py
|
||||||
|
│ ├── services/ # 业务逻辑层
|
||||||
|
│ │ ├── auth.py # 认证服务(密码哈希、JWT)
|
||||||
|
│ │ ├── query.py # 查询词业务逻辑
|
||||||
|
│ │ └── citation.py # 引用数据与报告服务
|
||||||
|
│ ├── workers/ # 后台任务与引擎
|
||||||
|
│ │ ├── scheduler.py # APScheduler 定时调度器
|
||||||
|
│ │ ├── citation_engine.py
|
||||||
|
│ │ └── platforms/ # AI 平台适配器
|
||||||
|
│ │ ├── base.py
|
||||||
|
│ │ ├── wenxin.py # 文心一言
|
||||||
|
│ │ ├── kimi.py # Kimi
|
||||||
|
│ │ ├── tongyi.py # 通义千问
|
||||||
|
│ │ ├── doubao.py # 豆包
|
||||||
|
│ │ ├── qingyan.py # 清言
|
||||||
|
│ │ ├── tiangong.py # 天工
|
||||||
|
│ │ ├── xinghuo.py # 讯飞星火
|
||||||
|
│ │ └── search_engine.py
|
||||||
|
│ ├── config.py # Pydantic Settings 配置管理
|
||||||
|
│ ├── database.py # 异步数据库引擎与 Session
|
||||||
|
│ └── main.py # FastAPI 应用入口与生命周期管理
|
||||||
|
├── requirements.txt
|
||||||
|
├── Dockerfile
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 限流策略
|
||||||
|
|
||||||
|
| 接口 | 限制 | 时间窗口 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 登录/注册/忘记密码 | 5 次 | 60 秒 |
|
||||||
|
| 立即查询(run-now) | 10 次 | 3600 秒 |
|
||||||
|
| 全局接口 | 100 次 | 60 秒 |
|
||||||
|
|
||||||
|
## 测试说明
|
||||||
|
|
||||||
|
测试文件位于项目根目录的 `tests/` 文件夹中。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装测试依赖(已包含在 requirements.txt 中)
|
||||||
|
pip install pytest pytest-asyncio aiosqlite
|
||||||
|
|
||||||
|
# 运行所有测试
|
||||||
|
cd .. # 切换到项目根目录
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# 运行指定测试文件
|
||||||
|
pytest tests/test_auth.py
|
||||||
|
pytest tests/test_queries.py
|
||||||
|
pytest tests/test_citations.py
|
||||||
|
pytest tests/test_citation_engine.py
|
||||||
|
pytest tests/test_scheduler.py
|
||||||
|
pytest tests/test_business_flow.py
|
||||||
|
|
||||||
|
# 显示详细输出
|
||||||
|
pytest -v
|
||||||
|
|
||||||
|
# 显示测试覆盖率(需安装 pytest-cov)
|
||||||
|
pytest --cov=backend/app --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
## 主要依赖版本
|
||||||
|
|
||||||
|
| 包名 | 版本要求 |
|
||||||
|
|------|---------|
|
||||||
|
| fastapi | >=0.109.0 |
|
||||||
|
| uvicorn | [standard] |
|
||||||
|
| sqlalchemy | >=2.0 |
|
||||||
|
| asyncpg | - |
|
||||||
|
| alembic | - |
|
||||||
|
| pydantic | >=2.0 |
|
||||||
|
| pydantic-settings | - |
|
||||||
|
| python-jose | [cryptography] |
|
||||||
|
| passlib | [bcrypt] |
|
||||||
|
| bcrypt | <4.0 |
|
||||||
|
| redis | - |
|
||||||
|
| apscheduler | >=3.10 |
|
||||||
|
| playwright | >=1.40 |
|
||||||
|
| httpx | - |
|
||||||
|
| fpdf2 | >=2.7 |
|
||||||
|
| pytest | >=8.0 |
|
||||||
|
| pytest-asyncio | >=0.23.0 |
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""Add user management fields
|
||||||
|
|
||||||
|
Revision ID: c3d5e7f9ab12
|
||||||
|
Revises: b2c4d6e8fa10
|
||||||
|
Create Date: 2026-04-24 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c3d5e7f9ab12'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'b2c4d6e8fa10'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add user management fields to users table."""
|
||||||
|
op.add_column('users', sa.Column('email_verified', sa.Boolean(), server_default='false', nullable=False))
|
||||||
|
op.add_column('users', sa.Column('verification_code', sa.String(6), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('verification_code_expires', sa.DateTime(), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('reset_token', sa.String(255), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('reset_token_expires', sa.DateTime(), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('avatar_url', sa.String(500), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('is_admin', sa.Boolean(), server_default='false', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove user management fields from users table."""
|
||||||
|
op.drop_column('users', 'is_admin')
|
||||||
|
op.drop_column('users', 'avatar_url')
|
||||||
|
op.drop_column('users', 'reset_token_expires')
|
||||||
|
op.drop_column('users', 'reset_token')
|
||||||
|
op.drop_column('users', 'verification_code_expires')
|
||||||
|
op.drop_column('users', 'verification_code')
|
||||||
|
op.drop_column('users', 'email_verified')
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.admin import (
|
||||||
|
get_system_stats,
|
||||||
|
get_user_detail,
|
||||||
|
get_users,
|
||||||
|
toggle_user_active,
|
||||||
|
update_user_plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="需要管理员权限",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def read_system_stats(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin_user: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
return await get_system_stats(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def read_users(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin_user: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
return await get_users(db, skip=skip, limit=limit, search=search)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}")
|
||||||
|
async def read_user_detail(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin_user: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
detail = await get_user_detail(db, user_id)
|
||||||
|
if detail is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在",
|
||||||
|
)
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/toggle-active")
|
||||||
|
async def toggle_user_active_status(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin_user: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
result = await toggle_user_active(db, user_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePlanRequest:
|
||||||
|
plan: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/update-plan")
|
||||||
|
async def modify_user_plan(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
body: dict,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin_user: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
plan = body.get("plan")
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="缺少 plan 字段",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await update_user_plan(db, user_id, plan)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="用户不存在",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
@ -4,8 +4,28 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.auth import TokenResponse, UserLogin, UserRegister, UserResponse
|
from app.schemas.auth import (
|
||||||
from app.services.auth import authenticate_user, create_access_token, register_user
|
ChangePasswordRequest,
|
||||||
|
ForgotPasswordRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
TokenResponse,
|
||||||
|
UpdateProfileRequest,
|
||||||
|
UserLogin,
|
||||||
|
UserRegister,
|
||||||
|
UserResponse,
|
||||||
|
VerifyEmailRequest,
|
||||||
|
)
|
||||||
|
from app.services.auth import (
|
||||||
|
authenticate_user,
|
||||||
|
change_password as change_password_service,
|
||||||
|
create_access_token,
|
||||||
|
register_user,
|
||||||
|
reset_password as reset_password_service,
|
||||||
|
send_reset_link,
|
||||||
|
send_verification_code,
|
||||||
|
update_profile as update_profile_service,
|
||||||
|
verify_email as verify_email_service,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -40,3 +60,55 @@ async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def read_current_user(current_user: User = Depends(get_current_user)):
|
async def read_current_user(current_user: User = Depends(get_current_user)):
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password")
|
||||||
|
async def forgot_password(req: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
await send_reset_link(db, req.email)
|
||||||
|
return {"message": "如果该邮箱已注册,重置链接已发送"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password")
|
||||||
|
async def reset_password(req: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
success = await reset_password_service(db, req.token, req.new_password)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的令牌或令牌已过期")
|
||||||
|
return {"message": "密码重置成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify-email")
|
||||||
|
async def verify_email(req: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
success = await verify_email_service(db, req.email, req.code)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码无效或已过期")
|
||||||
|
return {"message": "邮箱验证成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resend-verification")
|
||||||
|
async def resend_verification(req: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
await send_verification_code(db, req.email)
|
||||||
|
return {"message": "验证码已重新发送"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/change-password")
|
||||||
|
async def change_password(
|
||||||
|
req: ChangePasswordRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
success = await change_password_service(db, user.id, req.old_password, req.new_password)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
|
||||||
|
return {"message": "密码修改成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/profile", response_model=UserResponse)
|
||||||
|
async def update_profile(
|
||||||
|
req: UpdateProfileRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
updated_user = await update_profile_service(db, user.id, req)
|
||||||
|
if not updated_user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
return updated_user
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.citation import export_citations_csv
|
from app.services.citation import export_citations_csv, export_citations_pdf
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -44,3 +46,29 @@ async def export_report(
|
||||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/pdf")
|
||||||
|
async def export_pdf(
|
||||||
|
query_id: Optional[uuid.UUID] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
pdf_bytes = await export_citations_pdf(db, current_user.id, query_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
date_str = datetime.now().strftime("%Y%m%d")
|
||||||
|
filename = f"geo-report-{date_str}.pdf"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.subscription import (
|
||||||
|
PlanDetail,
|
||||||
|
SubscribeRequest,
|
||||||
|
SubscriptionHistoryResponse,
|
||||||
|
SubscriptionResponse,
|
||||||
|
)
|
||||||
|
from app.services.subscription import (
|
||||||
|
cancel_subscription,
|
||||||
|
get_current_subscription,
|
||||||
|
get_plans,
|
||||||
|
get_subscription_history,
|
||||||
|
subscribe,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/subscriptions", tags=["subscriptions"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plans", response_model=list[PlanDetail])
|
||||||
|
async def list_plans():
|
||||||
|
return get_plans()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/current", response_model=SubscriptionResponse)
|
||||||
|
async def read_current_subscription(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
sub = await get_current_subscription(db, current_user.id)
|
||||||
|
if sub is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="暂无订阅记录",
|
||||||
|
)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscribe", response_model=SubscriptionResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_subscription(
|
||||||
|
request: SubscribeRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
sub = await subscribe(db, current_user.id, request.plan)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cancel")
|
||||||
|
async def cancel_current_subscription(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await cancel_subscription(db, current_user.id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history", response_model=SubscriptionHistoryResponse)
|
||||||
|
async def read_subscription_history(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
items = await get_subscription_history(db, current_user.id)
|
||||||
|
return {"items": items, "total": len(items)}
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s"
|
||||||
|
)
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.admin import router as admin_router
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.citations import router as citations_router
|
from app.api.citations import router as citations_router
|
||||||
from app.api.queries import router as queries_router
|
from app.api.queries import router as queries_router
|
||||||
from app.api.reports import router as reports_router
|
from app.api.reports import router as reports_router
|
||||||
|
from app.api.subscriptions import router as subscription_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine, Base
|
from app.database import engine, Base
|
||||||
|
from app.middleware.rate_limit import RateLimitMiddleware
|
||||||
|
from app.middleware.logging_middleware import RequestLoggingMiddleware
|
||||||
from app.workers.scheduler import query_scheduler
|
from app.workers.scheduler import query_scheduler
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,10 +54,28 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 安全响应头
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_security_headers(request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
return response
|
||||||
|
|
||||||
|
# 限流中间件
|
||||||
|
app.add_middleware(RateLimitMiddleware)
|
||||||
|
|
||||||
|
# 请求日志中间件
|
||||||
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api/v1/auth", tags=["认证"])
|
app.include_router(auth_router, prefix="/api/v1/auth", tags=["认证"])
|
||||||
app.include_router(queries_router, prefix="/api/v1/queries", tags=["查询词"])
|
app.include_router(queries_router, prefix="/api/v1/queries", tags=["查询词"])
|
||||||
app.include_router(citations_router, prefix="/api/v1/citations", tags=["引用数据"])
|
app.include_router(citations_router, prefix="/api/v1/citations", tags=["引用数据"])
|
||||||
app.include_router(reports_router, prefix="/api/v1/reports", tags=["报告"])
|
app.include_router(reports_router, prefix="/api/v1/reports", tags=["报告"])
|
||||||
|
app.include_router(subscription_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
logger = logging.getLogger("geo.access")
|
||||||
|
|
||||||
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
start_time = time.time()
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
duration = round((time.time() - start_time) * 1000, 2)
|
||||||
|
logger.info(
|
||||||
|
f"{request.method} {request.url.path} "
|
||||||
|
f"status={response.status_code} "
|
||||||
|
f"duration={duration}ms "
|
||||||
|
f"ip={client_ip}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""
|
||||||
|
基于内存的简易限流中间件(MVP不依赖Redis)
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(self, app):
|
||||||
|
super().__init__(app)
|
||||||
|
# {key: [(timestamp, ...)]}
|
||||||
|
self._requests = defaultdict(list)
|
||||||
|
|
||||||
|
# 限流规则
|
||||||
|
self.rules = {
|
||||||
|
"auth": { # /api/v1/auth/login, register, forgot-password
|
||||||
|
"paths": ["/api/v1/auth/login", "/api/v1/auth/register", "/api/v1/auth/forgot-password"],
|
||||||
|
"max_requests": 5,
|
||||||
|
"window_seconds": 60,
|
||||||
|
},
|
||||||
|
"query_run": { # run-now
|
||||||
|
"paths": ["/run-now"], # 用 endswith 匹配
|
||||||
|
"max_requests": 10,
|
||||||
|
"window_seconds": 3600,
|
||||||
|
},
|
||||||
|
"global": {
|
||||||
|
"max_requests": 100,
|
||||||
|
"window_seconds": 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
path = request.url.path
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# 健康检查不限流
|
||||||
|
if path == "/health" or path.startswith("/docs") or path.startswith("/openapi"):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# 检查认证接口限流
|
||||||
|
if any(path == p for p in self.rules["auth"]["paths"]):
|
||||||
|
key = f"auth:{client_ip}"
|
||||||
|
if self._is_rate_limited(key, now, self.rules["auth"]):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={"detail": "请求过于频繁,请稍后再试"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查查询执行限流
|
||||||
|
if path.endswith("/run-now") and request.method == "POST":
|
||||||
|
key = f"query_run:{client_ip}"
|
||||||
|
if self._is_rate_limited(key, now, self.rules["query_run"]):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={"detail": "查询执行过于频繁,请稍后再试"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 全局限流
|
||||||
|
key = f"global:{client_ip}"
|
||||||
|
if self._is_rate_limited(key, now, self.rules["global"]):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={"detail": "请求过于频繁,请稍后再试"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
def _is_rate_limited(self, key, now, rule):
|
||||||
|
window = rule["window_seconds"]
|
||||||
|
max_req = rule["max_requests"]
|
||||||
|
|
||||||
|
# 清理过期记录
|
||||||
|
self._requests[key] = [t for t in self._requests[key] if now - t < window]
|
||||||
|
|
||||||
|
if len(self._requests[key]) >= max_req:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._requests[key].append(now)
|
||||||
|
return False
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import String, Boolean, Integer, func
|
from sqlalchemy import String, Boolean, Integer, DateTime, func
|
||||||
from sqlalchemy import Uuid
|
from sqlalchemy import Uuid
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
@ -22,6 +22,13 @@ class User(Base):
|
||||||
plan: Mapped[str] = mapped_column(String(20), default="free")
|
plan: Mapped[str] = mapped_column(String(20), default="free")
|
||||||
max_queries: Mapped[int] = mapped_column(Integer, default=5)
|
max_queries: Mapped[int] = mapped_column(Integer, default=5)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
verification_code: Mapped[str | None] = mapped_column(String(6), nullable=True)
|
||||||
|
verification_code_expires: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
reset_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
reset_token_expires: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
@ -15,6 +16,30 @@ class UserLogin(BaseModel):
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
new_password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyEmailRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
code: str = Field(..., min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateProfileRequest(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
email: str
|
email: str
|
||||||
|
|
@ -22,6 +47,9 @@ class UserResponse(BaseModel):
|
||||||
plan: str
|
plan: str
|
||||||
max_queries: int
|
max_queries: int
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
email_verified: bool
|
||||||
|
is_admin: bool
|
||||||
|
avatar_url: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
class PlanFeature(BaseModel):
|
||||||
|
name: str
|
||||||
|
included: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PlanDetail(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
price: float
|
||||||
|
max_queries: int
|
||||||
|
features: List[PlanFeature]
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeRequest(BaseModel):
|
||||||
|
plan: str
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
plan: str
|
||||||
|
status: str
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
amount: Optional[float] = None
|
||||||
|
payment_method: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionHistoryResponse(BaseModel):
|
||||||
|
items: List[SubscriptionResponse]
|
||||||
|
total: int
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.citation_record import CitationRecord
|
||||||
|
from app.models.query import Query
|
||||||
|
from app.models.subscription import Subscription
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.subscription import PLANS
|
||||||
|
|
||||||
|
|
||||||
|
async def get_system_stats(db: AsyncSession) -> dict:
|
||||||
|
total_users_result = await db.execute(select(func.count()).select_from(User))
|
||||||
|
total_users = total_users_result.scalar_one()
|
||||||
|
|
||||||
|
total_queries_result = await db.execute(select(func.count()).select_from(Query))
|
||||||
|
total_queries = total_queries_result.scalar_one()
|
||||||
|
|
||||||
|
total_citations_result = await db.execute(
|
||||||
|
select(func.count()).select_from(CitationRecord)
|
||||||
|
)
|
||||||
|
total_citations = total_citations_result.scalar_one()
|
||||||
|
|
||||||
|
cited_result = await db.execute(
|
||||||
|
select(func.count()).select_from(CitationRecord).where(CitationRecord.cited.is_(True))
|
||||||
|
)
|
||||||
|
cited_count = cited_result.scalar_one()
|
||||||
|
|
||||||
|
citation_rate = round(cited_count / total_citations * 100, 2) if total_citations > 0 else 0.0
|
||||||
|
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
today_start = datetime(today.year, today.month, today.day)
|
||||||
|
today_active_result = await db.execute(
|
||||||
|
select(func.count(func.distinct(Query.user_id))).where(Query.last_queried_at >= today_start)
|
||||||
|
)
|
||||||
|
today_active_users = today_active_result.scalar_one()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"total_queries": total_queries,
|
||||||
|
"total_citations": total_citations,
|
||||||
|
"citation_rate": citation_rate,
|
||||||
|
"today_active_users": today_active_users,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_users(
|
||||||
|
db: AsyncSession, skip: int = 0, limit: int = 20, search: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
base_stmt = select(User)
|
||||||
|
count_stmt = select(func.count()).select_from(User)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
like_pattern = f"%{search}%"
|
||||||
|
base_stmt = base_stmt.where(
|
||||||
|
(User.email.ilike(like_pattern)) | (User.name.ilike(like_pattern))
|
||||||
|
)
|
||||||
|
count_stmt = count_stmt.where(
|
||||||
|
(User.email.ilike(like_pattern)) | (User.name.ilike(like_pattern))
|
||||||
|
)
|
||||||
|
|
||||||
|
base_stmt = base_stmt.order_by(User.created_at.desc()).offset(skip).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(base_stmt)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
count_result = await db.execute(count_stmt)
|
||||||
|
total = count_result.scalar_one()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for user in users:
|
||||||
|
query_count_result = await db.execute(
|
||||||
|
select(func.count()).select_from(Query).where(Query.user_id == user.id)
|
||||||
|
)
|
||||||
|
query_count = query_count_result.scalar_one()
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"name": user.name,
|
||||||
|
"plan": user.plan,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"is_admin": user.is_admin,
|
||||||
|
"email_verified": user.email_verified,
|
||||||
|
"query_count": query_count,
|
||||||
|
"created_at": user.created_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"items": items, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_detail(db: AsyncSession, user_id: uuid.UUID) -> dict | None:
|
||||||
|
user_result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
queries_result = await db.execute(
|
||||||
|
select(Query).where(Query.user_id == user_id).order_by(Query.created_at.desc())
|
||||||
|
)
|
||||||
|
queries = queries_result.scalars().all()
|
||||||
|
|
||||||
|
citations_result = await db.execute(
|
||||||
|
select(CitationRecord)
|
||||||
|
.join(Query, CitationRecord.query_id == Query.id)
|
||||||
|
.where(Query.user_id == user_id)
|
||||||
|
.order_by(CitationRecord.queried_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
citations = citations_result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": {
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"name": user.name,
|
||||||
|
"plan": user.plan,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"is_admin": user.is_admin,
|
||||||
|
"email_verified": user.email_verified,
|
||||||
|
"max_queries": user.max_queries,
|
||||||
|
"created_at": user.created_at,
|
||||||
|
"updated_at": user.updated_at,
|
||||||
|
},
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"id": q.id,
|
||||||
|
"keyword": q.keyword,
|
||||||
|
"target_brand": q.target_brand,
|
||||||
|
"status": q.status,
|
||||||
|
"frequency": q.frequency,
|
||||||
|
"created_at": q.created_at,
|
||||||
|
}
|
||||||
|
for q in queries
|
||||||
|
],
|
||||||
|
"recent_citations": [
|
||||||
|
{
|
||||||
|
"id": c.id,
|
||||||
|
"platform": c.platform,
|
||||||
|
"cited": c.cited,
|
||||||
|
"citation_position": c.citation_position,
|
||||||
|
"queried_at": c.queried_at,
|
||||||
|
}
|
||||||
|
for c in citations
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_user_active(db: AsyncSession, user_id: uuid.UUID) -> dict | None:
|
||||||
|
user_result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user.is_active = not user.is_active
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"message": "用户已启用" if user.is_active else "用户已禁用",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_plan(db: AsyncSession, user_id: uuid.UUID, plan: str) -> dict | None:
|
||||||
|
plan_data = PLANS.get(plan)
|
||||||
|
if plan_data is None:
|
||||||
|
raise ValueError(f"Invalid plan: {plan}")
|
||||||
|
|
||||||
|
user_result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user.plan = plan
|
||||||
|
user.max_queries = plan_data["max_queries"]
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"plan": user.plan,
|
||||||
|
"max_queries": user.max_queries,
|
||||||
|
"message": f"用户套餐已更新为{plan_data['name']}",
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from jose import jwt, JWTError
|
from jose import jwt, JWTError
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
@ -8,8 +10,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.auth import UserRegister
|
from app.schemas.auth import UserRegister, UpdateProfileRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,7 +26,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
|
||||||
def create_access_token(data: dict) -> str:
|
def create_access_token(data: dict) -> str:
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS)
|
expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRE_HOURS)
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm="HS256")
|
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm="HS256")
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
@ -66,3 +69,106 @@ async def authenticate_user(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def send_verification_code(db: AsyncSession, email: str) -> None:
|
||||||
|
"""生成6位随机验证码,存到user记录,日志输出(模拟邮件)"""
|
||||||
|
stmt = select(User).where(User.email == email)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
return
|
||||||
|
|
||||||
|
code = f"{random.randint(100000, 999999)}"
|
||||||
|
user.verification_code = code
|
||||||
|
user.verification_code_expires = datetime.utcnow() + timedelta(minutes=10)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"[模拟邮件] 邮箱验证码发送到 {email}: {code}")
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_email(db: AsyncSession, email: str, code: str) -> bool:
|
||||||
|
"""验证码校验,成功则设置email_verified=True"""
|
||||||
|
stmt = select(User).where(User.email == email)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user.verification_code != code:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user.verification_code_expires is None or user.verification_code_expires < datetime.utcnow():
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.email_verified = True
|
||||||
|
user.verification_code = None
|
||||||
|
user.verification_code_expires = None
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def send_reset_link(db: AsyncSession, email: str) -> None:
|
||||||
|
"""生成UUID token,存到user记录,日志输出重置链接"""
|
||||||
|
stmt = select(User).where(User.email == email)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
return
|
||||||
|
|
||||||
|
token = str(uuid.uuid4())
|
||||||
|
user.reset_token = token
|
||||||
|
user.reset_token_expires = datetime.utcnow() + timedelta(hours=1)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"[模拟邮件] 密码重置链接: http://localhost:3000/reset-password?token={token}")
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_password(db: AsyncSession, token: str, new_password: str) -> bool:
|
||||||
|
"""token验证+密码更新"""
|
||||||
|
stmt = select(User).where(User.reset_token == token)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user.reset_token_expires is None or user.reset_token_expires < datetime.utcnow():
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
user.reset_token = None
|
||||||
|
user.reset_token_expires = None
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def change_password(db: AsyncSession, user_id: uuid.UUID, old_password: str, new_password: str) -> bool:
|
||||||
|
"""旧密码验证后更新"""
|
||||||
|
stmt = select(User).where(User.id == user_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not verify_password(old_password, user.password_hash):
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def update_profile(db: AsyncSession, user_id: uuid.UUID, data: UpdateProfileRequest) -> User | None:
|
||||||
|
"""更新用户资料(name, avatar_url)"""
|
||||||
|
stmt = select(User).where(User.id == user_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if data.name is not None:
|
||||||
|
user.name = data.name
|
||||||
|
if data.avatar_url is not None:
|
||||||
|
user.avatar_url = data.avatar_url
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select, and_, cast, Integer
|
from sqlalchemy import func, select, and_, cast, Integer
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.models.citation_record import CitationRecord
|
from app.models.citation_record import CitationRecord
|
||||||
|
|
@ -339,6 +340,132 @@ PLATFORM_NAMES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def export_citations_pdf(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
query_id: uuid.UUID | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""生成PDF格式报告"""
|
||||||
|
import os
|
||||||
|
from fpdf import FPDF
|
||||||
|
|
||||||
|
# 验证查询所有权(如果提供了 query_id)
|
||||||
|
if query_id is not None:
|
||||||
|
query = await _verify_query_ownership(db, query_id, user_id)
|
||||||
|
if query is None:
|
||||||
|
raise ValueError("Query not found")
|
||||||
|
|
||||||
|
# 构建查询条件
|
||||||
|
conditions = [Query.user_id == user_id]
|
||||||
|
if query_id is not None:
|
||||||
|
conditions.append(CitationRecord.query_id == query_id)
|
||||||
|
|
||||||
|
# 查询数据,使用 selectinload 加载 query 关系
|
||||||
|
stmt = (
|
||||||
|
select(CitationRecord)
|
||||||
|
.options(selectinload(CitationRecord.query))
|
||||||
|
.join(Query, CitationRecord.query_id == Query.id)
|
||||||
|
.where(and_(*conditions))
|
||||||
|
.order_by(CitationRecord.queried_at.desc())
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
records = result.scalars().all()
|
||||||
|
|
||||||
|
pdf = FPDF()
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
# 加载中文字体
|
||||||
|
font_paths = [
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
]
|
||||||
|
font_loaded = False
|
||||||
|
for fp in font_paths:
|
||||||
|
if os.path.exists(fp):
|
||||||
|
pdf.add_font("Chinese", "", fp, uni=True)
|
||||||
|
pdf.set_font("Chinese", size=12)
|
||||||
|
font_loaded = True
|
||||||
|
break
|
||||||
|
if not font_loaded:
|
||||||
|
pdf.set_font("Helvetica", size=12)
|
||||||
|
|
||||||
|
# 封面
|
||||||
|
pdf.set_font_size(24)
|
||||||
|
pdf.cell(0, 40, "GEO 品牌曝光度分析报告", new_x="LMARGIN", new_y="NEXT", align="C")
|
||||||
|
pdf.set_font_size(12)
|
||||||
|
pdf.cell(0, 10, f"生成日期: {datetime.now().strftime('%Y-%m-%d %H:%M')}", new_x="LMARGIN", new_y="NEXT", align="C")
|
||||||
|
pdf.ln(20)
|
||||||
|
|
||||||
|
# 汇总统计
|
||||||
|
total = len(records)
|
||||||
|
cited_count = sum(1 for r in records if r.cited)
|
||||||
|
rate = f"{cited_count / total * 100:.1f}%" if total > 0 else "0%"
|
||||||
|
|
||||||
|
total_position = 0
|
||||||
|
position_count = 0
|
||||||
|
for r in records:
|
||||||
|
if r.citation_position is not None:
|
||||||
|
total_position += r.citation_position
|
||||||
|
position_count += 1
|
||||||
|
avg_pos = f"{total_position / position_count:.1f}" if position_count > 0 else "-"
|
||||||
|
|
||||||
|
pdf.set_font_size(16)
|
||||||
|
pdf.cell(0, 12, "一、汇总统计", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.set_font_size(11)
|
||||||
|
pdf.cell(0, 8, f"总查询次数: {total}", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.cell(0, 8, f"引用次数: {cited_count}", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.cell(0, 8, f"引用率: {rate}", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.cell(0, 8, f"平均引用位置: {avg_pos}", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.ln(10)
|
||||||
|
|
||||||
|
# 平台分布
|
||||||
|
pdf.set_font_size(16)
|
||||||
|
pdf.cell(0, 12, "二、平台分布", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.set_font_size(11)
|
||||||
|
|
||||||
|
platform_stats = {}
|
||||||
|
for r in records:
|
||||||
|
if r.platform not in platform_stats:
|
||||||
|
platform_stats[r.platform] = {"total": 0, "cited": 0}
|
||||||
|
platform_stats[r.platform]["total"] += 1
|
||||||
|
if r.cited:
|
||||||
|
platform_stats[r.platform]["cited"] += 1
|
||||||
|
|
||||||
|
for platform, stats in platform_stats.items():
|
||||||
|
name = PLATFORM_NAMES.get(platform, platform)
|
||||||
|
p_rate = f"{stats['cited'] / stats['total'] * 100:.1f}%" if stats['total'] > 0 else "0%"
|
||||||
|
pdf.cell(0, 8, f" {name}: 查询{stats['total']}次, 引用{stats['cited']}次, 引用率{p_rate}", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.ln(10)
|
||||||
|
|
||||||
|
# 详细数据表格
|
||||||
|
pdf.set_font_size(16)
|
||||||
|
pdf.cell(0, 12, "三、详细数据", new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.set_font_size(9)
|
||||||
|
|
||||||
|
col_widths = [30, 25, 20, 20, 15, 80]
|
||||||
|
headers = ["查询关键词", "平台", "是否引用", "置信度", "位置", "引用文本"]
|
||||||
|
for i, h in enumerate(headers):
|
||||||
|
pdf.cell(col_widths[i], 8, h, border=1, align="C")
|
||||||
|
pdf.ln()
|
||||||
|
|
||||||
|
for r in records:
|
||||||
|
keyword = r.query.keyword if r.query else ""
|
||||||
|
platform_name = PLATFORM_NAMES.get(r.platform, r.platform)
|
||||||
|
cited_str = "是" if r.cited else "否"
|
||||||
|
conf = f"{r.confidence:.2f}" if r.confidence is not None else "-"
|
||||||
|
pos = str(r.citation_position) if r.citation_position is not None else "-"
|
||||||
|
text = (r.citation_text or "")[:40]
|
||||||
|
|
||||||
|
row_data = [keyword[:15], platform_name, cited_str, conf, pos, text]
|
||||||
|
for i, d in enumerate(row_data):
|
||||||
|
pdf.cell(col_widths[i], 7, d, border=1)
|
||||||
|
pdf.ln()
|
||||||
|
|
||||||
|
return pdf.output()
|
||||||
|
|
||||||
|
|
||||||
async def export_citations_csv(
|
async def export_citations_csv(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.subscription import Subscription
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.subscription import PlanDetail, PlanFeature, SubscriptionResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PLAN_FEATURES = [
|
||||||
|
("基础查询监控", ["free", "starter", "pro", "enterprise"]),
|
||||||
|
("CSV导出", ["free", "starter", "pro", "enterprise"]),
|
||||||
|
("PDF报告", ["starter", "pro", "enterprise"]),
|
||||||
|
("定时查询", ["pro", "enterprise"]),
|
||||||
|
("竞品分析", ["pro", "enterprise"]),
|
||||||
|
("API访问", ["enterprise"]),
|
||||||
|
("专属支持", ["enterprise"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
PLANS = {
|
||||||
|
"free": {
|
||||||
|
"name": "免费版",
|
||||||
|
"price": 0,
|
||||||
|
"max_queries": 5,
|
||||||
|
},
|
||||||
|
"starter": {
|
||||||
|
"name": "入门版",
|
||||||
|
"price": 99,
|
||||||
|
"max_queries": 20,
|
||||||
|
},
|
||||||
|
"pro": {
|
||||||
|
"name": "专业版",
|
||||||
|
"price": 299,
|
||||||
|
"max_queries": 100,
|
||||||
|
},
|
||||||
|
"enterprise": {
|
||||||
|
"name": "企业版",
|
||||||
|
"price": 999,
|
||||||
|
"max_queries": 500,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_features(plan_id: str) -> list[PlanFeature]:
|
||||||
|
return [
|
||||||
|
PlanFeature(name=name, included=plan_id in allowed)
|
||||||
|
for name, allowed in _PLAN_FEATURES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_plans() -> list[PlanDetail]:
|
||||||
|
return [
|
||||||
|
PlanDetail(
|
||||||
|
id=plan_id,
|
||||||
|
name=data["name"],
|
||||||
|
price=data["price"],
|
||||||
|
max_queries=data["max_queries"],
|
||||||
|
features=_build_features(plan_id),
|
||||||
|
)
|
||||||
|
for plan_id, data in PLANS.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_subscription(
|
||||||
|
db: AsyncSession, user_id: uuid.UUID
|
||||||
|
) -> Optional[SubscriptionResponse]:
|
||||||
|
stmt = (
|
||||||
|
select(Subscription)
|
||||||
|
.where(Subscription.user_id == user_id)
|
||||||
|
.order_by(Subscription.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
sub = result.scalar_one_or_none()
|
||||||
|
if sub is None:
|
||||||
|
return None
|
||||||
|
return SubscriptionResponse.model_validate(sub)
|
||||||
|
|
||||||
|
|
||||||
|
async def subscribe(
|
||||||
|
db: AsyncSession, user_id: uuid.UUID, plan: str
|
||||||
|
) -> SubscriptionResponse:
|
||||||
|
plan_data = PLANS.get(plan)
|
||||||
|
if plan_data is None:
|
||||||
|
raise ValueError(f"Invalid plan: {plan}")
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
end_date = today + timedelta(days=30)
|
||||||
|
|
||||||
|
subscription = Subscription(
|
||||||
|
user_id=user_id,
|
||||||
|
plan=plan,
|
||||||
|
status="active",
|
||||||
|
start_date=today,
|
||||||
|
end_date=end_date,
|
||||||
|
amount=plan_data["price"],
|
||||||
|
payment_method="模拟支付",
|
||||||
|
)
|
||||||
|
db.add(subscription)
|
||||||
|
|
||||||
|
user_stmt = select(User).where(User.id == user_id)
|
||||||
|
user_result = await db.execute(user_stmt)
|
||||||
|
user = user_result.scalar_one()
|
||||||
|
user.plan = plan
|
||||||
|
user.max_queries = plan_data["max_queries"]
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(subscription)
|
||||||
|
|
||||||
|
logger.info(f"[模拟支付] 用户{user_id} 订阅{plan},金额{plan_data['price']}元")
|
||||||
|
|
||||||
|
return SubscriptionResponse.model_validate(subscription)
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_subscription(db: AsyncSession, user_id: uuid.UUID) -> dict:
|
||||||
|
stmt = (
|
||||||
|
select(Subscription)
|
||||||
|
.where(Subscription.user_id == user_id, Subscription.status == "active")
|
||||||
|
.order_by(Subscription.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
sub = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if sub is not None:
|
||||||
|
sub.status = "cancelled"
|
||||||
|
|
||||||
|
user_stmt = select(User).where(User.id == user_id)
|
||||||
|
user_result = await db.execute(user_stmt)
|
||||||
|
user = user_result.scalar_one()
|
||||||
|
user.plan = "free"
|
||||||
|
user.max_queries = PLANS["free"]["max_queries"]
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"message": "订阅已取消,已降级到免费版"}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_subscription_history(
|
||||||
|
db: AsyncSession, user_id: uuid.UUID
|
||||||
|
) -> list[SubscriptionResponse]:
|
||||||
|
stmt = (
|
||||||
|
select(Subscription)
|
||||||
|
.where(Subscription.user_id == user_id)
|
||||||
|
.order_by(Subscription.created_at.desc())
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
subs = result.scalars().all()
|
||||||
|
return [SubscriptionResponse.model_validate(sub) for sub in subs]
|
||||||
|
|
@ -33,3 +33,6 @@ python-dotenv
|
||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
pytest-asyncio>=0.23.0
|
pytest-asyncio>=0.23.0
|
||||||
aiosqlite
|
aiosqlite
|
||||||
|
|
||||||
|
# PDF生成
|
||||||
|
fpdf2>=2.7
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,169 @@
|
||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# GEO 平台 - 前端应用
|
||||||
|
|
||||||
## Getting Started
|
基于 Next.js 14 + React 18 构建的现代化前端应用,使用 App Router 架构。
|
||||||
|
|
||||||
First, run the development server:
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm 9+(或 yarn、pnpm)
|
||||||
|
- 后端服务已启动(默认 http://localhost:8000)
|
||||||
|
|
||||||
|
## 安装步骤
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
cd frontend
|
||||||
# or
|
npm install
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
## 运行命令
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
```bash
|
||||||
|
# 开发模式(带热重载)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
# 生产构建
|
||||||
|
npm run build
|
||||||
|
|
||||||
## Learn More
|
# 启动生产服务
|
||||||
|
npm run start
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
# 代码检查
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
开发服务器启动后,访问 http://localhost:3000。
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
## 页面路由说明
|
||||||
|
|
||||||
## Deploy on Vercel
|
本应用使用 Next.js App Router(`app/` 目录),路由基于文件系统自动生成。
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
### 认证页面(`(auth)` 路由组)
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
| 路径 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/` | 首页/欢迎页 | 应用入口,未登录用户展示介绍 |
|
||||||
|
| `/login` | 用户登录 | 邮箱+密码登录,支持记住我 |
|
||||||
|
| `/register` | 用户注册 | 新用户注册,含邮箱验证流程 |
|
||||||
|
| `/forgot-password` | 忘记密码 | 发送密码重置链接到邮箱 |
|
||||||
|
| `/reset-password` | 重置密码 | 通过令牌设置新密码 |
|
||||||
|
|
||||||
|
### 仪表盘页面(`(dashboard)` 路由组)
|
||||||
|
|
||||||
|
所有仪表盘页面均需登录后才能访问,受 NextAuth 会话保护。
|
||||||
|
|
||||||
|
| 路径 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/dashboard` | 仪表盘首页 | 数据概览、关键指标、引用趋势图 |
|
||||||
|
| `/dashboard/queries` | 查询词管理 | 查询词 CRUD、即时执行、分页列表 |
|
||||||
|
| `/dashboard/citations` | 引用数据 | 引用记录列表、按平台/日期筛选 |
|
||||||
|
| `/dashboard/reports` | 报告导出 | CSV 报告生成与下载 |
|
||||||
|
| `/dashboard/settings` | 个人设置 | 用户资料修改、密码修改 |
|
||||||
|
|
||||||
|
### API 路由
|
||||||
|
|
||||||
|
| 路径 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `/api/auth/[...nextauth]` | NextAuth.js 认证 API 端点 |
|
||||||
|
|
||||||
|
## 组件结构说明
|
||||||
|
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
├── ui/ # shadcn/ui 基础组件
|
||||||
|
│ ├── button.tsx # 按钮
|
||||||
|
│ ├── card.tsx # 卡片
|
||||||
|
│ ├── dialog.tsx # 对话框
|
||||||
|
│ ├── dropdown-menu.tsx # 下拉菜单
|
||||||
|
│ ├── input.tsx # 输入框
|
||||||
|
│ ├── label.tsx # 标签
|
||||||
|
│ ├── select.tsx # 下拉选择
|
||||||
|
│ ├── table.tsx # 表格
|
||||||
|
│ ├── tabs.tsx # 标签页
|
||||||
|
│ ├── badge.tsx # 徽标
|
||||||
|
│ └── skeleton.tsx # 骨架屏
|
||||||
|
├── charts/ # 数据可视化图表组件
|
||||||
|
│ ├── trend-chart.tsx # 引用趋势折线图
|
||||||
|
│ └── platform-chart.tsx # 平台对比柱状图/饼图
|
||||||
|
├── layout/ # 布局组件
|
||||||
|
│ ├── header.tsx # 顶部导航栏
|
||||||
|
│ └── sidebar.tsx # 侧边栏导航
|
||||||
|
└── providers.tsx # 全局 Provider 包装(NextAuth SessionProvider 等)
|
||||||
|
```
|
||||||
|
|
||||||
|
### lib 工具库
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── api.ts # 后端 API 客户端封装(fetch 封装)
|
||||||
|
├── auth.ts # NextAuth 配置与工具函数
|
||||||
|
├── platforms.ts # 平台名称/图标映射配置
|
||||||
|
└── utils.ts # 通用工具函数(cn 合并类等)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈详情
|
||||||
|
|
||||||
|
| 类别 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 框架 | Next.js 14 (App Router) |
|
||||||
|
| UI 库 | React 18 |
|
||||||
|
| 样式 | TailwindCSS 3.4 |
|
||||||
|
| 组件库 | shadcn/ui(基于 Radix UI) |
|
||||||
|
| 图表 | Recharts |
|
||||||
|
| 认证 | NextAuth.js 4 (Credentials Provider) |
|
||||||
|
| 字体 | Geist(本地字体) |
|
||||||
|
| 语言 | TypeScript 5 |
|
||||||
|
|
||||||
|
## 环境变量配置
|
||||||
|
|
||||||
|
前端环境变量分为两类:
|
||||||
|
|
||||||
|
### 公开环境变量(`NEXT_PUBLIC_` 前缀,客户端可用)
|
||||||
|
|
||||||
|
在 `.env.local` 中配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端 API 地址
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务端环境变量(仅服务端可用)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NextAuth 配置
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET=geo-platform-nextauth-secret-key-2026
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:`.env.local` 文件不会被提交到 Git 仓库。新成员需要手动创建该文件。
|
||||||
|
|
||||||
|
### 环境变量文件优先级
|
||||||
|
|
||||||
|
Next.js 按以下优先级加载环境变量(高优先级覆盖低优先级):
|
||||||
|
|
||||||
|
1. `.env.local`(本地开发,不提交 Git)
|
||||||
|
2. `.env.development` / `.env.production`(按环境)
|
||||||
|
3. `.env`(全局默认)
|
||||||
|
|
||||||
|
## Docker 开发
|
||||||
|
|
||||||
|
项目根目录已提供 `docker-compose.yml`,前端服务会自动挂载代码并启动开发服务器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在项目根目录执行
|
||||||
|
docker-compose up -d frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
前端容器配置:
|
||||||
|
- 端口映射:`3000:3000`
|
||||||
|
- 代码热重载:通过 volume 挂载 `./frontend:/app`
|
||||||
|
- node_modules 持久化:独立 volume 避免覆盖
|
||||||
|
|
||||||
|
## 开发注意事项
|
||||||
|
|
||||||
|
1. **API 调用**:统一使用 `lib/api.ts` 中封装的方法,自动携带 JWT Token
|
||||||
|
2. **认证状态**:通过 `next-auth/react` 的 `useSession` Hook 获取当前登录状态
|
||||||
|
3. **服务端组件 vs 客户端组件**:
|
||||||
|
- 数据获取页面优先使用 Server Component
|
||||||
|
- 交互逻辑(表单、图表)使用 `"use client"` Client Component
|
||||||
|
4. **样式规范**:统一使用 TailwindCSS 工具类,复杂组合通过 `cn()` 工具函数处理
|
||||||
|
5. **图标**:统一使用 `lucide-react` 图标库
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await api.auth.forgotPassword(email);
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "请求失败";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl">重置链接已发送</CardTitle>
|
||||||
|
<CardDescription>重置链接已发送到您的邮箱,请查收</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
如果您没有收到邮件,请检查垃圾邮件文件夹或稍后再试。
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl">忘记密码</CardTitle>
|
||||||
|
<CardDescription>输入您的邮箱地址,我们将发送重置链接</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">邮箱</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col space-y-4">
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "发送中..." : "发送重置链接"}
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -64,7 +64,15 @@ export default function LoginPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">密码</Label>
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
忘记密码?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ export default function RegisterPage() {
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setError("注册成功但自动登录失败,请手动登录");
|
setError("注册成功但自动登录失败,请手动登录");
|
||||||
|
router.push("/login");
|
||||||
} else {
|
} else {
|
||||||
router.push("/dashboard");
|
router.push(`/verify-email?email=${encodeURIComponent(email)}`);
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : "注册失败";
|
const message = err instanceof Error ? err.message : "注册失败";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
function ResetPasswordForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState(3);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (success && countdown > 0) {
|
||||||
|
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (success && countdown === 0) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [success, countdown, router]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError("两次输入的密码不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setError("重置令牌无效或已过期");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.auth.resetPassword(token, newPassword);
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "重置失败";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl">密码重置成功</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
您的密码已重置,{countdown} 秒后自动跳转到登录页
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter>
|
||||||
|
<Link href="/login" className="text-sm text-primary hover:underline">
|
||||||
|
立即登录
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl">重置密码</CardTitle>
|
||||||
|
<CardDescription>请输入您的新密码</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newPassword">新密码</Label>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入新密码"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col space-y-4">
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "提交中..." : "重置密码"}
|
||||||
|
</Button>
|
||||||
|
<Link href="/login" className="text-sm text-primary hover:underline">
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl">重置密码</CardTitle>
|
||||||
|
<CardDescription>加载中...</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResetPasswordForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
function VerifyEmailForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const email = searchParams.get("email") || "";
|
||||||
|
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [resendCountdown, setResendCountdown] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendCountdown > 0) {
|
||||||
|
const timer = setTimeout(() => setResendCountdown((c) => c - 1), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [resendCountdown]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!email) {
|
||||||
|
setError("邮箱地址无效");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await api.auth.verifyEmail(email, code);
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push("/login");
|
||||||
|
}, 1500);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "验证失败";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
if (!email || resendCountdown > 0) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await api.auth.resendVerification(email);
|
||||||
|
setResendCountdown(60);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "发送失败";
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl">验证成功</CardTitle>
|
||||||
|
<CardDescription>邮箱验证成功,正在跳转到登录页...</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter>
|
||||||
|
<Link href="/login" className="text-sm text-primary hover:underline">
|
||||||
|
立即登录
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl">验证邮箱</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
我们已向 <span className="font-medium">{email || "您的邮箱"}</span> 发送了验证码
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="code">验证码</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入6位验证码"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={resendCountdown > 0 || !email}
|
||||||
|
>
|
||||||
|
{resendCountdown > 0
|
||||||
|
? `${resendCountdown} 秒后重新发送`
|
||||||
|
: "重新发送验证码"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col space-y-4">
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "验证中..." : "验证"}
|
||||||
|
</Button>
|
||||||
|
<Link href="/login" className="text-sm text-primary hover:underline">
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl">验证邮箱</CardTitle>
|
||||||
|
<CardDescription>加载中...</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<VerifyEmailForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,434 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
Quote,
|
||||||
|
Percent,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Ban,
|
||||||
|
UserCheck,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface StatsData {
|
||||||
|
total_users: number;
|
||||||
|
total_queries: number;
|
||||||
|
total_citations: number;
|
||||||
|
citation_rate: number;
|
||||||
|
today_active_users: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
plan: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_admin: boolean;
|
||||||
|
email_verified: boolean;
|
||||||
|
query_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAN_OPTIONS = [
|
||||||
|
{ value: "free", label: "免费版" },
|
||||||
|
{ value: "starter", label: "入门版" },
|
||||||
|
{ value: "pro", label: "专业版" },
|
||||||
|
{ value: "business", label: "企业版" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LIMIT = 10;
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [stats, setStats] = useState<StatsData | null>(null);
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [totalUsers, setTotalUsers] = useState(0);
|
||||||
|
const [skip, setSkip] = useState(0);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [loadingStats, setLoadingStats] = useState(false);
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dialogType, setDialogType] = useState<"toggle" | "plan">("toggle");
|
||||||
|
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState("");
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
const token = session?.accessToken;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
loadStats();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
loadUsers();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [token, skip, search]);
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
if (!token) return;
|
||||||
|
setLoadingStats(true);
|
||||||
|
try {
|
||||||
|
const data = await api.admin.getStats(token);
|
||||||
|
setStats(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "加载统计失败");
|
||||||
|
} finally {
|
||||||
|
setLoadingStats(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
if (!token) return;
|
||||||
|
setLoadingUsers(true);
|
||||||
|
try {
|
||||||
|
const data = await api.admin.getUsers(token, { skip, limit: LIMIT, search: search || undefined });
|
||||||
|
setUsers(data.items || []);
|
||||||
|
setTotalUsers(data.total || 0);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "加载用户列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoadingUsers(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openToggleDialog(user: AdminUser) {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setDialogType("toggle");
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPlanDialog(user: AdminUser) {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setSelectedPlan(user.plan);
|
||||||
|
setDialogType("plan");
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!token || !selectedUser) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
if (dialogType === "toggle") {
|
||||||
|
const res = await api.admin.toggleUserActive(token, selectedUser.id);
|
||||||
|
setSuccess(res.message || "操作成功");
|
||||||
|
} else {
|
||||||
|
const res = await api.admin.updateUserPlan(token, selectedUser.id, selectedPlan);
|
||||||
|
setSuccess(res.message || "套餐更新成功");
|
||||||
|
}
|
||||||
|
await loadUsers();
|
||||||
|
setDialogOpen(false);
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "操作失败");
|
||||||
|
setDialogOpen(false);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalUsers / LIMIT);
|
||||||
|
const currentPage = Math.floor(skip / LIMIT) + 1;
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
title: "总用户数",
|
||||||
|
value: stats?.total_users ?? 0,
|
||||||
|
icon: Users,
|
||||||
|
color: "text-blue-600",
|
||||||
|
bg: "bg-blue-50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "总查询数",
|
||||||
|
value: stats?.total_queries ?? 0,
|
||||||
|
icon: Search,
|
||||||
|
color: "text-emerald-600",
|
||||||
|
bg: "bg-emerald-50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "总引用次数",
|
||||||
|
value: stats?.total_citations ?? 0,
|
||||||
|
icon: Quote,
|
||||||
|
color: "text-violet-600",
|
||||||
|
bg: "bg-violet-50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "引用率",
|
||||||
|
value: stats ? `${stats.citation_rate}%` : "0%",
|
||||||
|
icon: Percent,
|
||||||
|
color: "text-amber-600",
|
||||||
|
bg: "bg-amber-50",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString("zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">管理后台</h2>
|
||||||
|
<p className="text-muted-foreground">系统统计与用户管理</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||||
|
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<Card key={card.title}>
|
||||||
|
<CardContent className="flex items-center gap-4 p-6">
|
||||||
|
<div className={cn("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
|
||||||
|
<card.icon className={cn("h-6 w-6", card.color)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{card.title}</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{loadingStats ? <Loader2 className="h-5 w-5 animate-spin" /> : card.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Management */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">用户管理</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索邮箱或用户名"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setSkip(0);
|
||||||
|
}}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" onClick={() => { setSearch(""); setSkip(0); }}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>邮箱</TableHead>
|
||||||
|
<TableHead>用户名</TableHead>
|
||||||
|
<TableHead>套餐</TableHead>
|
||||||
|
<TableHead>查询数</TableHead>
|
||||||
|
<TableHead>邮箱验证</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>注册日期</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loadingUsers ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||||
|
暂无用户数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.email}</TableCell>
|
||||||
|
<TableCell>{user.name || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{user.plan}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{user.query_count}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.email_verified ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
|
||||||
|
<CheckCircle className="h-3 w-3" /> 已验证
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Ban className="h-3 w-3" /> 未验证
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.is_active ? (
|
||||||
|
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">
|
||||||
|
正常
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="destructive">禁用</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{formatDate(user.created_at)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openToggleDialog(user)}
|
||||||
|
>
|
||||||
|
{user.is_active ? <Ban className="h-3 w-3 mr-1" /> : <UserCheck className="h-3 w-3 mr-1" />}
|
||||||
|
{user.is_active ? "禁用" : "启用"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openPlanDialog(user)}
|
||||||
|
>
|
||||||
|
修改套餐
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSkip((p) => Math.max(0, p - LIMIT))}
|
||||||
|
disabled={skip === 0}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
第 {currentPage} / {totalPages} 页 (共 {totalUsers} 条)
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSkip((p) => p + LIMIT)}
|
||||||
|
disabled={skip + LIMIT >= totalUsers}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{dialogType === "toggle"
|
||||||
|
? selectedUser?.is_active
|
||||||
|
? "禁用用户"
|
||||||
|
: "启用用户"
|
||||||
|
: "修改套餐"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dialogType === "toggle"
|
||||||
|
? `确认${selectedUser?.is_active ? "禁用" : "启用"}用户 ${selectedUser?.email}?`
|
||||||
|
: `请选择用户 ${selectedUser?.email} 的新套餐`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{dialogType === "plan" && (
|
||||||
|
<div className="py-2">
|
||||||
|
<Select value={selectedPlan} onValueChange={setSelectedPlan}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择套餐" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLAN_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={actionLoading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} disabled={actionLoading}>
|
||||||
|
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(...classes: (string | undefined | false)[]) {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useSession } from "next-auth/react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -12,28 +13,71 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { Loader2, FileDown, Info, CheckCircle, AlertTriangle } from "lucide-react";
|
import {
|
||||||
|
Loader2,
|
||||||
|
FileDown,
|
||||||
|
FileText,
|
||||||
|
Info,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Search,
|
||||||
|
Quote,
|
||||||
|
Percent,
|
||||||
|
BarChart3,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface QueryOption {
|
interface QueryOption {
|
||||||
id: string;
|
id: string;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CitationStats {
|
||||||
|
total_queries: number;
|
||||||
|
total_citations: number;
|
||||||
|
citation_rate: number;
|
||||||
|
avg_position: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CitationRecord {
|
||||||
|
id: string;
|
||||||
|
query_id: string;
|
||||||
|
platform: string;
|
||||||
|
cited: boolean;
|
||||||
|
citation_position: number | null;
|
||||||
|
citation_text: string | null;
|
||||||
|
competitor_brands: string[];
|
||||||
|
confidence: number | null;
|
||||||
|
match_type: string | null;
|
||||||
|
queried_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [queries, setQueries] = useState<QueryOption[]>([]);
|
const [queries, setQueries] = useState<QueryOption[]>([]);
|
||||||
const [selectedQuery, setSelectedQuery] = useState<string>("");
|
const [selectedQuery, setSelectedQuery] = useState<string>("");
|
||||||
const [exportFormat, setExportFormat] = useState<string>("csv");
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<CitationStats | null>(null);
|
||||||
|
const [recentCitations, setRecentCitations] = useState<CitationRecord[]>([]);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.accessToken) return;
|
if (!session?.accessToken) return;
|
||||||
loadQueries();
|
loadQueries();
|
||||||
|
loadPreview();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [session?.accessToken]);
|
}, [session?.accessToken]);
|
||||||
|
|
||||||
|
|
@ -47,38 +91,71 @@ export default function ReportsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExport() {
|
async function loadPreview() {
|
||||||
|
if (!session?.accessToken) return;
|
||||||
|
setPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const statsData = await api.citations.getStats(session.accessToken);
|
||||||
|
setStats(statsData);
|
||||||
|
const listData = await api.citations.list(session.accessToken, "limit=10");
|
||||||
|
setRecentCitations(listData.items || []);
|
||||||
|
} catch (err) {
|
||||||
|
// 预览数据加载失败不影响主功能
|
||||||
|
console.error("预览数据加载失败", err);
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportCSV() {
|
||||||
if (!session?.accessToken) return;
|
if (!session?.accessToken) return;
|
||||||
if (!selectedQuery) {
|
if (!selectedQuery) {
|
||||||
setError("请先选择要导出的查询词");
|
setError("请先选择要导出的查询词");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await handleExport("csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportPDF() {
|
||||||
|
if (!session?.accessToken) return;
|
||||||
|
if (!selectedQuery) {
|
||||||
|
setError("请先选择要导出的查询词");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleExport("pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport(format: "csv" | "pdf") {
|
||||||
|
if (!session?.accessToken) return;
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
|
|
||||||
// 使用原生fetch下载CSV文件
|
const queryId = selectedQuery;
|
||||||
const query = `?query_id=${selectedQuery}`;
|
let blob: Blob;
|
||||||
const url = `${API_BASE}/api/v1/reports/export/csv${query}`;
|
let filename: string;
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${session.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (format === "csv") {
|
||||||
const errorData = await res.json().catch(() => ({ detail: "导出失败" }));
|
const query = `?query_id=${queryId}`;
|
||||||
throw new Error(errorData.detail || `HTTP ${res.status}`);
|
const url = `${API_BASE}/api/v1/reports/export/csv${query}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({ detail: "导出失败" }));
|
||||||
|
throw new Error(errorData.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
blob = await res.blob();
|
||||||
|
filename = `report_${queryId}_${new Date().toISOString().split("T")[0]}.csv`;
|
||||||
|
} else {
|
||||||
|
blob = await api.reports.exportPDF(session.accessToken, queryId);
|
||||||
|
filename = `report_${queryId}_${new Date().toISOString().split("T")[0]}.pdf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await res.blob();
|
|
||||||
const downloadUrl = window.URL.createObjectURL(blob);
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = downloadUrl;
|
a.href = downloadUrl;
|
||||||
const filename = selectedQuery
|
|
||||||
? `report_${selectedQuery}_${new Date().toISOString().split("T")[0]}.csv`
|
|
||||||
: `report_all_${new Date().toISOString().split("T")[0]}.csv`;
|
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
|
|
@ -93,8 +170,45 @@ export default function ReportsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
title: "总查询",
|
||||||
|
value: stats?.total_queries ?? 0,
|
||||||
|
icon: Search,
|
||||||
|
color: "text-blue-600",
|
||||||
|
bg: "bg-blue-50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "引用次数",
|
||||||
|
value: stats?.total_citations ?? 0,
|
||||||
|
icon: Quote,
|
||||||
|
color: "text-emerald-600",
|
||||||
|
bg: "bg-emerald-50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "引用率",
|
||||||
|
value: stats ? `${stats.citation_rate.toFixed(1)}%` : "0%",
|
||||||
|
icon: Percent,
|
||||||
|
color: "text-violet-600",
|
||||||
|
bg: "bg-violet-50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "平均位置",
|
||||||
|
value: stats?.avg_position ? stats.avg_position.toFixed(1) : "-",
|
||||||
|
icon: BarChart3,
|
||||||
|
color: "text-amber-600",
|
||||||
|
bg: "bg-amber-50",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString("zh-CN") + " " + d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">报告导出</h2>
|
<h2 className="text-2xl font-bold tracking-tight">报告导出</h2>
|
||||||
<p className="text-muted-foreground">导出引用检测数据为报告文件</p>
|
<p className="text-muted-foreground">导出引用检测数据为报告文件</p>
|
||||||
|
|
@ -126,21 +240,6 @@ export default function ReportsPage() {
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="format-select">导出格式</Label>
|
|
||||||
<Select value={exportFormat} onValueChange={setExportFormat}>
|
|
||||||
<SelectTrigger id="format-select">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="csv">CSV 表格</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
目前仅支持 CSV 格式导出
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
|
@ -155,18 +254,33 @@ export default function ReportsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
onClick={handleExport}
|
<Button
|
||||||
disabled={loading}
|
onClick={handleExportCSV}
|
||||||
className="w-full"
|
disabled={loading}
|
||||||
>
|
className="flex-1"
|
||||||
{loading ? (
|
>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
{loading ? (
|
||||||
) : (
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<FileDown className="mr-2 h-4 w-4" />
|
) : (
|
||||||
)}
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
导出报告
|
)}
|
||||||
</Button>
|
导出 CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExportPDF}
|
||||||
|
disabled={loading}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
导出 PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -179,7 +293,7 @@ export default function ReportsPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||||
<p>1. 选择要导出的查询词</p>
|
<p>1. 选择要导出的查询词</p>
|
||||||
<p>2. 目前支持 CSV 格式,可用 Excel 或 WPS 打开</p>
|
<p>2. 支持 CSV 和 PDF 两种格式导出</p>
|
||||||
<p>3. 导出文件包含以下字段:</p>
|
<p>3. 导出文件包含以下字段:</p>
|
||||||
<ul className="ml-4 list-disc space-y-1">
|
<ul className="ml-4 list-disc space-y-1">
|
||||||
<li>查询关键词</li>
|
<li>查询关键词</li>
|
||||||
|
|
@ -194,6 +308,94 @@ export default function ReportsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Preview */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">数据概览</h3>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<Card key={card.title}>
|
||||||
|
<CardContent className="flex items-center gap-4 p-6">
|
||||||
|
<div className={cn("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
|
||||||
|
<card.icon className={cn("h-6 w-6", card.color)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{card.title}</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{previewLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : card.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Citations */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">最近引用记录</CardTitle>
|
||||||
|
<CardDescription>最近 10 条引用检测结果</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>平台</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>位置</TableHead>
|
||||||
|
<TableHead>引用文本</TableHead>
|
||||||
|
<TableHead>竞争品牌</TableHead>
|
||||||
|
<TableHead>检测时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{previewLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : recentCitations.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||||
|
暂无引用记录
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
recentCitations.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="font-medium">{c.platform}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{c.cited ? (
|
||||||
|
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">
|
||||||
|
已引用
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">未引用</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{c.citation_position ?? "-"}</TableCell>
|
||||||
|
<TableCell className="max-w-xs truncate" title={c.citation_text || ""}>
|
||||||
|
{c.citation_text || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{c.competitor_brands?.length > 0 ? c.competitor_brands.join(", ") : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{formatDate(c.queried_at)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cn(...classes: (string | undefined | false)[]) {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,405 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { User, Crown, Check, X } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Crown, Check, X, Loader2, AlertTriangle, CheckCircle } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
const PLANS = [
|
interface PlanFeature {
|
||||||
{
|
name: string;
|
||||||
key: "free",
|
included: boolean;
|
||||||
name: "免费版",
|
}
|
||||||
price: "¥0",
|
|
||||||
period: "/月",
|
interface PlanDetail {
|
||||||
features: [
|
id: string;
|
||||||
{ text: "最多 3 个查询词", included: true },
|
name: string;
|
||||||
{ text: "每日查询 1 次", included: true },
|
price: number;
|
||||||
{ text: "基础报告导出", included: true },
|
max_queries: number;
|
||||||
{ text: "2 个平台监测", included: true },
|
features: PlanFeature[];
|
||||||
{ text: "高级分析图表", included: false },
|
}
|
||||||
{ text: "竞品对比分析", included: false },
|
|
||||||
{ text: "API 接口访问", included: false },
|
interface SubscriptionData {
|
||||||
{ text: "优先技术支持", included: false },
|
id: string;
|
||||||
],
|
plan: string;
|
||||||
},
|
status: string;
|
||||||
{
|
start_date: string;
|
||||||
key: "starter",
|
end_date: string;
|
||||||
name: "入门版",
|
amount: number | null;
|
||||||
price: "¥99",
|
payment_method: string | null;
|
||||||
period: "/月",
|
created_at: string;
|
||||||
features: [
|
}
|
||||||
{ text: "最多 10 个查询词", included: true },
|
|
||||||
{ text: "每日查询 3 次", included: true },
|
function ProfileTab() {
|
||||||
{ text: "基础报告导出", included: true },
|
const { data: session, update } = useSession();
|
||||||
{ text: "4 个平台监测", included: true },
|
const [name, setName] = useState(session?.user?.name || "");
|
||||||
{ text: "高级分析图表", included: true },
|
const [loading, setLoading] = useState(false);
|
||||||
{ text: "竞品对比分析", included: false },
|
const [error, setError] = useState("");
|
||||||
{ text: "API 接口访问", included: false },
|
const [success, setSuccess] = useState(false);
|
||||||
{ text: "优先技术支持", included: false },
|
|
||||||
],
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
},
|
e.preventDefault();
|
||||||
{
|
if (!session?.accessToken) return;
|
||||||
key: "pro",
|
setLoading(true);
|
||||||
name: "专业版",
|
setError("");
|
||||||
price: "¥299",
|
setSuccess(false);
|
||||||
period: "/月",
|
try {
|
||||||
features: [
|
await api.auth.updateProfile(session.accessToken, { name });
|
||||||
{ text: "最多 50 个查询词", included: true },
|
await update({ name });
|
||||||
{ text: "每日查询 10 次", included: true },
|
setSuccess(true);
|
||||||
{ text: "高级报告导出", included: true },
|
} catch (err: unknown) {
|
||||||
{ text: "全部平台监测", included: true },
|
const message = err instanceof Error ? err.message : "保存失败";
|
||||||
{ text: "高级分析图表", included: true },
|
setError(message);
|
||||||
{ text: "竞品对比分析", included: true },
|
} finally {
|
||||||
{ text: "API 接口访问", included: false },
|
setLoading(false);
|
||||||
{ text: "优先技术支持", included: true },
|
}
|
||||||
],
|
};
|
||||||
},
|
|
||||||
{
|
return (
|
||||||
key: "business",
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
name: "企业版",
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
price: "¥999",
|
{success && <p className="text-sm text-emerald-600">个人资料已更新</p>}
|
||||||
period: "/月",
|
<div className="space-y-2">
|
||||||
features: [
|
<Label htmlFor="profileName">用户名</Label>
|
||||||
{ text: "无限查询词", included: true },
|
<Input
|
||||||
{ text: "无限查询次数", included: true },
|
id="profileName"
|
||||||
{ text: "定制化报告", included: true },
|
value={name}
|
||||||
{ text: "全部平台监测", included: true },
|
onChange={(e) => setName(e.target.value)}
|
||||||
{ text: "高级分析图表", included: true },
|
placeholder="请输入用户名"
|
||||||
{ text: "竞品对比分析", included: true },
|
/>
|
||||||
{ text: "API 接口访问", included: true },
|
</div>
|
||||||
{ text: "专属客户经理", included: true },
|
<div className="space-y-2">
|
||||||
],
|
<Label htmlFor="profileEmail">邮箱</Label>
|
||||||
},
|
<Input
|
||||||
];
|
id="profileEmail"
|
||||||
|
type="email"
|
||||||
|
value={session?.user?.email || ""}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordTab() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [oldPassword, setOldPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError("新密码至少需要 8 位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError("两次输入的新密码不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!session?.accessToken) {
|
||||||
|
setError("登录已过期,请重新登录");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.auth.changePassword(session.accessToken, oldPassword, newPassword);
|
||||||
|
setSuccess(true);
|
||||||
|
setOldPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "修改失败";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
{success && <p className="text-sm text-emerald-600">密码修改成功</p>}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="oldPassword">当前密码</Label>
|
||||||
|
<Input
|
||||||
|
id="oldPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入当前密码"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newPassword">新密码</Label>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入新密码(至少8位)"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmNewPassword">确认新密码</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmNewPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "修改中..." : "修改密码"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriptionTab() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [plans, setPlans] = useState<PlanDetail[]>([]);
|
||||||
|
const [currentSub, setCurrentSub] = useState<SubscriptionData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dialogType, setDialogType] = useState<"subscribe" | "cancel">("subscribe");
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState("");
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
const token = session?.accessToken;
|
||||||
|
const currentPlan = currentSub?.plan || "free";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const plansData = await api.subscriptions.getPlans();
|
||||||
|
setPlans(plansData);
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const subData = await api.subscriptions.getCurrent(token);
|
||||||
|
setCurrentSub(subData);
|
||||||
|
} catch {
|
||||||
|
// 暂无订阅记录,保持 null
|
||||||
|
setCurrentSub(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "加载失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSubscribeDialog(planId: string) {
|
||||||
|
setSelectedPlan(planId);
|
||||||
|
setDialogType("subscribe");
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCancelDialog() {
|
||||||
|
setDialogType("cancel");
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!token) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
if (dialogType === "subscribe") {
|
||||||
|
await api.subscriptions.subscribe(token, selectedPlan);
|
||||||
|
setSuccess("订阅成功");
|
||||||
|
} else {
|
||||||
|
await api.subscriptions.cancel(token);
|
||||||
|
setSuccess("订阅已取消");
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
setDialogOpen(false);
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "操作失败");
|
||||||
|
setDialogOpen(false);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlanName = plans.find((p) => p.id === currentPlan)?.name || currentPlan;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||||
|
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">当前套餐</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
|
||||||
|
<Crown className="h-5 w-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{currentPlanName}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">当前套餐</Badge>
|
||||||
|
{currentSub?.status && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
状态: {currentSub.status === "active" ? "生效中" : currentSub.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{currentPlan !== "free" && currentSub && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>有效期: {currentSub.start_date} 至 {currentSub.end_date}</p>
|
||||||
|
{currentSub.amount && <p>金额: ¥{currentSub.amount}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentPlan !== "free" && (
|
||||||
|
<Button variant="destructive" size="sm" onClick={openCancelDialog}>
|
||||||
|
取消订阅
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">套餐对比</h3>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const isCurrent = plan.id === currentPlan;
|
||||||
|
const canUpgrade = plan.id !== currentPlan;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={plan.id}
|
||||||
|
className={isCurrent ? "border-primary ring-1 ring-primary" : ""}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">{plan.name}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<span className="text-2xl font-bold text-foreground">¥{plan.price}</span>
|
||||||
|
<span className="text-muted-foreground">/月</span>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{plan.features.map((feature, idx) => (
|
||||||
|
<li key={idx} className="flex items-center gap-2 text-sm">
|
||||||
|
{feature.included ? (
|
||||||
|
<Check className="h-4 w-4 shrink-0 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<X className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
<span className={feature.included ? "" : "text-muted-foreground/60"}>
|
||||||
|
{feature.name}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{isCurrent ? (
|
||||||
|
<Badge className="mt-4 w-full justify-center" variant="default">
|
||||||
|
当前套餐
|
||||||
|
</Badge>
|
||||||
|
) : canUpgrade ? (
|
||||||
|
<Button
|
||||||
|
className="mt-4 w-full"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openSubscribeDialog(plan.id)}
|
||||||
|
>
|
||||||
|
立即升级
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{dialogType === "subscribe" ? "确认订阅" : "取消订阅"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dialogType === "subscribe"
|
||||||
|
? `确认使用模拟支付订阅「${plans.find((p) => p.id === selectedPlan)?.name || selectedPlan}」套餐?`
|
||||||
|
: "确认取消当前订阅?取消后将在当前计费周期结束后恢复为免费版。"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={actionLoading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} disabled={actionLoading} variant={dialogType === "cancel" ? "destructive" : "default"}>
|
||||||
|
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { data: session } = useSession();
|
|
||||||
const currentPlan = "free"; // MVP阶段默认免费版
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -83,89 +407,38 @@ export default function SettingsPage() {
|
||||||
<p className="text-muted-foreground">管理您的账户和订阅信息</p>
|
<p className="text-muted-foreground">管理您的账户和订阅信息</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Tabs defaultValue="profile" className="w-full">
|
||||||
<CardHeader>
|
<TabsList>
|
||||||
<CardTitle className="text-base">用户信息</CardTitle>
|
<TabsTrigger value="profile">个人资料</TabsTrigger>
|
||||||
</CardHeader>
|
<TabsTrigger value="password">密码修改</TabsTrigger>
|
||||||
<CardContent className="space-y-4">
|
<TabsTrigger value="subscription">订阅管理</TabsTrigger>
|
||||||
<div className="flex items-center gap-3">
|
</TabsList>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
<TabsContent value="profile" className="mt-4">
|
||||||
<User className="h-5 w-5 text-primary" />
|
<Card>
|
||||||
</div>
|
<CardHeader>
|
||||||
<div>
|
<CardTitle className="text-base">个人资料</CardTitle>
|
||||||
<p className="font-medium">{session?.user?.name || "未设置姓名"}</p>
|
<CardDescription>管理您的基本信息</CardDescription>
|
||||||
<p className="text-sm text-muted-foreground">{session?.user?.email || "—"}</p>
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
</div>
|
<ProfileTab />
|
||||||
<div className="flex items-center gap-3">
|
</CardContent>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
|
</Card>
|
||||||
<Crown className="h-5 w-5 text-amber-600" />
|
</TabsContent>
|
||||||
</div>
|
<TabsContent value="password" className="mt-4">
|
||||||
<div>
|
<Card>
|
||||||
<p className="font-medium">当前套餐</p>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle className="text-base">密码修改</CardTitle>
|
||||||
<Badge variant="secondary">{PLANS.find((p) => p.key === currentPlan)?.name || "免费版"}</Badge>
|
<CardDescription>更改您的登录密码</CardDescription>
|
||||||
<span className="text-xs text-muted-foreground">MVP 阶段所有功能免费开放</span>
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
</div>
|
<PasswordTab />
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</TabsContent>
|
||||||
|
<TabsContent value="subscription" className="mt-4">
|
||||||
<div>
|
<SubscriptionTab />
|
||||||
<h3 className="mb-4 text-lg font-semibold">套餐对比</h3>
|
</TabsContent>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
</Tabs>
|
||||||
{PLANS.map((plan) => (
|
|
||||||
<Card
|
|
||||||
key={plan.key}
|
|
||||||
className={plan.key === currentPlan ? "border-primary ring-1 ring-primary" : ""}
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">{plan.name}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
<span className="text-2xl font-bold text-foreground">{plan.price}</span>
|
|
||||||
<span className="text-muted-foreground">{plan.period}</span>
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{plan.features.map((feature, idx) => (
|
|
||||||
<li key={idx} className="flex items-center gap-2 text-sm">
|
|
||||||
{feature.included ? (
|
|
||||||
<Check className="h-4 w-4 shrink-0 text-emerald-500" />
|
|
||||||
) : (
|
|
||||||
<X className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
<span className={feature.included ? "" : "text-muted-foreground/60"}>
|
|
||||||
{feature.text}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{plan.key === currentPlan && (
|
|
||||||
<Badge className="mt-4 w-full justify-center" variant="default">
|
|
||||||
当前套餐
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">MVP 阶段说明</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
|
||||||
<p className="mb-2">
|
|
||||||
当前处于 GEO 平台 MVP 阶段,所有功能均可免费使用。正式版上线后将启用套餐限制,届时您可以选择适合您的订阅方案。
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
如有任何问题或建议,欢迎通过平台反馈渠道联系我们。
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|
@ -9,9 +10,10 @@ import {
|
||||||
Quote,
|
Quote,
|
||||||
FileDown,
|
FileDown,
|
||||||
Settings,
|
Settings,
|
||||||
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const baseNavItems = [
|
||||||
{ name: "数据总览", href: "/dashboard", icon: LayoutDashboard },
|
{ name: "数据总览", href: "/dashboard", icon: LayoutDashboard },
|
||||||
{ name: "查询管理", href: "/dashboard/queries", icon: Search },
|
{ name: "查询管理", href: "/dashboard/queries", icon: Search },
|
||||||
{ name: "引用记录", href: "/dashboard/citations", icon: Quote },
|
{ name: "引用记录", href: "/dashboard/citations", icon: Quote },
|
||||||
|
|
@ -21,6 +23,11 @@ const navItems = [
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const navItems = session?.user?.is_admin
|
||||||
|
? [...baseNavItems, { name: "管理后台", href: "/dashboard/admin", icon: Shield }]
|
||||||
|
: baseNavItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 bg-slate-900 text-white">
|
<aside className="fixed left-0 top-0 z-40 h-screen w-64 bg-slate-900 text-white">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
|
#HttpOnly_localhost FALSE / FALSE 1779586904 next-auth.session-token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..H3E9QhqgbpDJMmkB.yYmEZnejk-WUCZMjLMDJdlVMZal-LyanhgkXx8GhcIv8qcQZvXxGzSpX8L4lV7R9wCqxJVCWBILAimid6d-sv4MpIcZioH8NyGqepvnWJoWhkbAxrxJvrhrB_h0ESo6VuvZ4doW7oV-3EftMNzvOpFl5Opv9Jjyo8vCDdimRaEOwnnxpkuGNTZ7Ca7C2o8HkI4I9h3vtzKFYxZNqCukwq5HY4pCfqE0LysTLXSd5Rg8BG9uUj4TH8UYjSBtoFrkvjDNGazS1kO0PEeq_U_Q0E4F17zg4k8TvVG0az0Q1TAvmcW5FBnb2mF7w0lNvE5b5ESXW1uW_JSEigXusOHbHlIrl9PafsUeek6MN3aKLUxt5cfvS2mKpLA0mWi2bMl5CmjoIzRnHU5Q2BZ2axzLDG_QiUMqaC1ghCklFM46DGkiSGsOVYk1Oz0yN2RJrrkOYsRdC1e9s8Y00imTaF-78EIdpCZQ2jePxk86Fbm5ju0wJFZVaTIx1Rk9WTYwvi_D0Bo1lpxABI1HVI9kz6o2r1VfAWLm_3poPZ0FDC4kKGiE.gzu1FubRqk9RqT1nNADojg
|
||||||
|
#HttpOnly_localhost FALSE / FALSE 0 next-auth.csrf-token 1966deebfa651641e0fea8b073e51a470ca0f344254ccc2b5112c26bcbc46578%7Cd44c731ad18fa1807b89db213db37e16079933f621105d553b8281ae869a9d7e
|
||||||
|
#HttpOnly_localhost FALSE / FALSE 0 next-auth.callback-url http%3A%2F%2Flocalhost%3A3000%2Fdashboard
|
||||||
|
|
@ -52,6 +52,36 @@ export const api = {
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
getMe: (token: string) => fetchWithAuth("/api/v1/auth/me", {}, token),
|
getMe: (token: string) => fetchWithAuth("/api/v1/auth/me", {}, token),
|
||||||
|
forgotPassword: (email: string) =>
|
||||||
|
fetchWithAuth("/api/v1/auth/forgot-password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
resetPassword: (token: string, newPassword: string) =>
|
||||||
|
fetchWithAuth("/api/v1/auth/reset-password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ token, new_password: newPassword }),
|
||||||
|
}),
|
||||||
|
verifyEmail: (email: string, code: string) =>
|
||||||
|
fetchWithAuth("/api/v1/auth/verify-email", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, code }),
|
||||||
|
}),
|
||||||
|
resendVerification: (email: string) =>
|
||||||
|
fetchWithAuth("/api/v1/auth/resend-verification", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
changePassword: (token: string, oldPassword: string, newPassword: string) =>
|
||||||
|
fetchWithAuth("/api/v1/auth/change-password", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
|
||||||
|
}, token),
|
||||||
|
updateProfile: (token: string, data: { name?: string; avatar_url?: string }) =>
|
||||||
|
fetchWithAuth("/api/v1/auth/profile", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}, token),
|
||||||
},
|
},
|
||||||
queries: {
|
queries: {
|
||||||
list: (token: string) => fetchWithAuth("/api/v1/queries/", {}, token),
|
list: (token: string) => fetchWithAuth("/api/v1/queries/", {}, token),
|
||||||
|
|
@ -69,10 +99,55 @@ export const api = {
|
||||||
fetchWithAuth(`/api/v1/citations/${params ? `?${params}` : ""}`, {}, token),
|
fetchWithAuth(`/api/v1/citations/${params ? `?${params}` : ""}`, {}, token),
|
||||||
getStats: (token: string) => fetchWithAuth("/api/v1/citations/stats/", {}, token),
|
getStats: (token: string) => fetchWithAuth("/api/v1/citations/stats/", {}, token),
|
||||||
},
|
},
|
||||||
|
subscriptions: {
|
||||||
|
getPlans: async () => {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/subscriptions/plans`);
|
||||||
|
if (!res.ok) throw new Error("获取套餐失败");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
getCurrent: async (token: string) =>
|
||||||
|
fetchWithAuth("/api/v1/subscriptions/current", {}, token),
|
||||||
|
subscribe: async (token: string, plan: string) =>
|
||||||
|
fetchWithAuth("/api/v1/subscriptions/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ plan }),
|
||||||
|
}, token),
|
||||||
|
cancel: async (token: string) =>
|
||||||
|
fetchWithAuth("/api/v1/subscriptions/cancel", { method: "POST" }, token),
|
||||||
|
getHistory: async (token: string) =>
|
||||||
|
fetchWithAuth("/api/v1/subscriptions/history", {}, token),
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
getStats: async (token: string) =>
|
||||||
|
fetchWithAuth("/api/v1/admin/stats", {}, token),
|
||||||
|
getUsers: async (token: string, params?: { skip?: number; limit?: number; search?: string }) => {
|
||||||
|
const query = params
|
||||||
|
? "?" + new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined) as [string, string][]).toString()
|
||||||
|
: "";
|
||||||
|
return fetchWithAuth(`/api/v1/admin/users${query}`, {}, token);
|
||||||
|
},
|
||||||
|
getUserDetail: async (token: string, userId: string) =>
|
||||||
|
fetchWithAuth(`/api/v1/admin/users/${userId}`, {}, token),
|
||||||
|
toggleUserActive: async (token: string, userId: string) =>
|
||||||
|
fetchWithAuth(`/api/v1/admin/users/${userId}/toggle-active`, { method: "POST" }, token),
|
||||||
|
updateUserPlan: async (token: string, userId: string, plan: string) =>
|
||||||
|
fetchWithAuth(`/api/v1/admin/users/${userId}/update-plan`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ plan }),
|
||||||
|
}, token),
|
||||||
|
},
|
||||||
reports: {
|
reports: {
|
||||||
exportCSV: (token: string, queryId?: string) => {
|
exportCSV: (token: string, queryId?: string) => {
|
||||||
const query = queryId ? `?query_id=${queryId}` : "";
|
const query = queryId ? `?query_id=${queryId}` : "";
|
||||||
return fetchWithAuth(`/api/v1/reports/export/csv${query}`, {}, token);
|
return fetchWithAuth(`/api/v1/reports/export/csv${query}`, {}, token);
|
||||||
},
|
},
|
||||||
|
exportPDF: async (token: string, queryId?: string) => {
|
||||||
|
const query = queryId ? `?query_id=${queryId}` : "";
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/reports/export/pdf${query}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("导出失败");
|
||||||
|
return res.blob();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,22 +11,37 @@ export const authOptions: NextAuthOptions = {
|
||||||
password: { label: "密码", type: "password" },
|
password: { label: "密码", type: "password" },
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
async authorize(credentials) {
|
||||||
if (!credentials?.email || !credentials?.password) return null;
|
console.log("[NextAuth] authorize called with email:", credentials?.email);
|
||||||
|
if (!credentials?.email || !credentials?.password) {
|
||||||
|
console.log("[NextAuth] missing credentials");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api.auth.login({
|
const res = await api.auth.login({
|
||||||
email: credentials.email,
|
email: credentials.email,
|
||||||
password: credentials.password,
|
password: credentials.password,
|
||||||
});
|
});
|
||||||
|
console.log("[NextAuth] login response:", JSON.stringify({
|
||||||
|
hasAccessToken: !!res.access_token,
|
||||||
|
userId: res.user?.id,
|
||||||
|
userEmail: res.user?.email,
|
||||||
|
isAdmin: res.user?.is_admin,
|
||||||
|
}));
|
||||||
if (res.access_token) {
|
if (res.access_token) {
|
||||||
return {
|
const user = {
|
||||||
id: res.user?.id || credentials.email,
|
id: res.user?.id || credentials.email,
|
||||||
name: res.user?.name,
|
name: res.user?.name,
|
||||||
email: res.user?.email,
|
email: res.user?.email,
|
||||||
accessToken: res.access_token,
|
accessToken: res.access_token,
|
||||||
|
is_admin: res.user?.is_admin || false,
|
||||||
};
|
};
|
||||||
|
console.log("[NextAuth] returning user:", JSON.stringify(user));
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
console.log("[NextAuth] no access_token in response");
|
||||||
return null;
|
return null;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("[NextAuth] authorize error:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -40,12 +55,14 @@ export const authOptions: NextAuthOptions = {
|
||||||
if (user) {
|
if (user) {
|
||||||
token.accessToken = user.accessToken;
|
token.accessToken = user.accessToken;
|
||||||
token.id = user.id;
|
token.id = user.id;
|
||||||
|
token.is_admin = user.is_admin;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
session.accessToken = token.accessToken as string;
|
session.accessToken = token.accessToken as string;
|
||||||
session.user.id = token.id as string;
|
session.user.id = token.id as string;
|
||||||
|
session.user.is_admin = token.is_admin as boolean;
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ declare module "next-auth" {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
|
is_admin?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
is_admin?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,5 +23,6 @@ declare module "next-auth/jwt" {
|
||||||
interface JWT {
|
interface JWT {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
is_admin?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
cd /Users/Chiguyong/Code/GEO
|
|
||||||
echo "=== Step 1: Check remote ==="
|
|
||||||
git remote -v
|
|
||||||
echo "=== Step 2: Git status ==="
|
|
||||||
git status
|
|
||||||
echo "=== Step 3: Git log ==="
|
|
||||||
git log --oneline -5
|
|
||||||
echo "=== Step 4: Push ==="
|
|
||||||
git push -u origin main 2>&1 || {
|
|
||||||
echo "Push failed, trying force push..."
|
|
||||||
git push -u origin main --force 2>&1
|
|
||||||
}
|
|
||||||
echo "=== Done ==="
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
hello2
|
|
||||||
Loading…
Reference in New Issue