diff --git a/README.md b/README.md
index f0712d0..6424ecc 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,49 @@
-# 🏗️ 结伴客项目
+# 解班客 - 宠物认领平台
-## 📋 项目概述
-结伴客是一个综合性的管理系统,包含后台管理、微信小程序和官方网站三个主要模块。
+一个基于Vue.js和Node.js的宠物认领平台,帮助流浪动物找到温暖的家。
+
+## 项目概述
+
+解班客是一个专业的宠物认领平台,致力于为流浪动物提供一个温暖的归宿。平台通过现代化的Web技术,为用户提供便捷的宠物发布、搜索、认领服务,同时为管理员提供完善的后台管理功能。
+
+### 核心功能
+
+- **用户系统**: 完整的用户注册、登录、个人信息管理
+- **动物管理**: 动物信息发布、编辑、状态管理
+- **认领流程**: 在线认领申请、审核、跟踪
+- **地图定位**: 基于地理位置的动物搜索和展示
+- **管理后台**: 用户管理、动物管理、数据统计、文件管理
+- **消息通知**: 实时消息推送和邮件通知
+- **数据统计**: 详细的业务数据分析和报表
+
+## 技术架构
+
+### 前端技术栈
+- **框架**: Vue.js 3.x + Composition API
+- **UI组件**: Element Plus
+- **状态管理**: Pinia
+- **路由**: Vue Router 4
+- **构建工具**: Vite
+- **HTTP客户端**: Axios
+- **样式**: SCSS + CSS Modules
+
+### 后端技术栈
+- **运行时**: Node.js 18+
+- **框架**: Express.js
+- **数据库**: MySQL 8.0
+- **缓存**: Redis 6.0
+- **认证**: JWT + Passport
+- **文件处理**: Multer + Sharp
+- **日志**: Winston
+- **测试**: Jest + Supertest
+
+### 基础设施
+- **容器化**: Docker + Docker Compose
+- **反向代理**: Nginx
+- **进程管理**: PM2
+- **监控**: Prometheus + Grafana
+- **日志收集**: ELK Stack
+- **CI/CD**: GitHub Actions
## 🗂️ 项目结构
@@ -55,6 +97,25 @@ cd website && npm run dev
所有详细文档位于 `docs/` 目录:
+### 📖 快速导航
+
+| 文档类型 | 文档名称 | 描述 | 适用人员 |
+|---------|---------|------|---------|
+| 🚀 快速开始 | [系统集成和部署文档](docs/系统集成和部署文档.md) | 环境搭建、部署流程 | 开发者、运维 |
+| 🔧 开发指南 | [前端开发文档](docs/前端开发文档.md) | 前端开发规范和指南 | 前端开发者 |
+| 🔧 开发指南 | [后端开发文档](docs/后端开发文档.md) | 后端开发规范和指南 | 后端开发者 |
+| 📋 API参考 | [API接口文档](docs/API接口文档.md) | 完整的API接口文档 | 全栈开发者 |
+| 🗄️ 数据设计 | [数据库设计文档](docs/数据库设计文档.md) | 数据库结构设计 | 后端开发者、DBA |
+| 👨💼 管理功能 | [管理员后台系统API文档](docs/管理员后台系统API文档.md) | 管理后台功能说明 | 管理员、开发者 |
+| 📁 文件系统 | [文件上传系统文档](docs/文件上传系统文档.md) | 文件上传和管理 | 全栈开发者 |
+| 🔍 监控运维 | [错误处理和日志系统文档](docs/错误处理和日志系统文档.md) | 错误处理和日志 | 开发者、运维 |
+| 🧪 质量保证 | [测试文档](docs/测试文档.md) | 测试策略、用例设计和质量保证 | 测试工程师、开发者 |
+| 🔒 安全管理 | [安全和权限管理文档](docs/安全和权限管理文档.md) | 安全策略、权限控制、安全防护措施 | 安全工程师、系统管理员 |
+| ⚡ 性能优化 | [性能优化文档](docs/性能优化文档.md) | 系统性能优化策略、监控方案和优化实践 | 性能工程师、运维工程师 |
+| 🚀 部署运维 | [部署和运维文档](docs/部署和运维文档.md) | 系统部署流程和运维管理方案 | 运维工程师、DevOps工程师 |
+| 📊 项目管理 | [项目开发进度报告](docs/项目开发进度报告.md) | 项目进度和规划 | 项目经理、开发者 |
+| 📝 开发规范 | [开发规范和最佳实践](docs/开发规范和最佳实践.md) | 代码规范和标准 | 全体开发者 |
+
### 核心文档
- 📄 [项目概述](docs/项目概述.md) - 项目背景、目标和整体介绍
- 📄 [系统架构文档](docs/系统架构文档.md) - 系统架构设计和技术栈
@@ -63,6 +124,25 @@ cd website && npm run dev
- 📄 [开发指南](docs/开发指南.md) - 开发环境搭建和开发规范
- 📄 [部署指南](docs/部署指南.md) - 开发、测试、生产环境部署指南
+### 功能模块文档
+
+| 文档名称 | 描述 | 链接 |
+|---------|------|------|
+| API接口文档 | 详细的API接口说明和使用示例 | [查看文档](./docs/API接口文档.md) |
+| 管理员后台文档 | 管理员功能和操作指南 | [查看文档](./docs/管理员后台文档.md) |
+| 用户认证系统文档 | 用户注册、登录、权限管理 | [查看文档](./docs/用户认证系统文档.md) |
+| 动物管理系统文档 | 动物信息管理和认领流程 | [查看文档](./docs/动物管理系统文档.md) |
+| 文件上传系统文档 | 文件上传、存储和管理 | [查看文档](./docs/文件上传系统文档.md) |
+| 数据库设计文档 | 数据库架构、表结构和关系设计 | [查看文档](./docs/数据库设计文档.md) |
+| 错误处理和日志系统文档 | 错误处理机制和日志记录 | [查看文档](./docs/错误处理和日志系统文档.md) |
+| 系统集成和部署文档 | 系统部署和运维指南 | [查看文档](./docs/系统集成和部署文档.md) |
+| 前端开发文档 | 前端技术架构、组件设计和开发规范 | [查看文档](./docs/前端开发文档.md) |
+
+#### 项目管理文档
+- **[项目开发进度报告](docs/项目开发进度报告.md)** - 项目进度跟踪和里程碑规划
+- **[开发规范和最佳实践](docs/开发规范和最佳实践.md)** - 团队开发规范和代码标准
+- **[测试文档](docs/测试文档.md)** - 测试策略、用例设计和质量保证
+
### 补充文档
- 📄 [变更日志](CHANGELOG.md) - 项目版本变更记录
- 📄 [贡献指南](docs/贡献指南.md) - 如何参与项目开发
diff --git a/admin-system/src/components/AdvancedSearch.vue b/admin-system/src/components/AdvancedSearch.vue
new file mode 100644
index 0000000..0d010b4
--- /dev/null
+++ b/admin-system/src/components/AdvancedSearch.vue
@@ -0,0 +1,666 @@
+
+
+
+
+
+
+ 高级搜索
+
+
+
+
+
+
+ 重置
+
+
+ {{ expanded ? '收起' : '展开' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ status.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 搜索
+
+
+ 重置
+
+
+
+
+
+
+
+
+
+
+
+ 普通用户
+ VIP用户
+ 管理员
+
+
+
+
+
+ 网页端
+ 微信小程序
+ 移动应用
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 狗
+ 猫
+ 鸟
+ 其他
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 雄性
+ 雌性
+ 未知
+
+
+
+
+
+ 健康
+ 生病
+ 受伤
+ 康复中
+
+
+
+
+
+
+
+
+ 认领
+ 捐赠
+ 服务
+
+
+
+
+
+
+
+
+
+
+
+
+ 微信支付
+ 支付宝
+ 银行卡
+
+
+
+
+
+
+
+
+
+
+
+ 创建时间降序
+ 创建时间升序
+ 更新时间降序
+ 更新时间升序
+ 名称升序
+ 名称降序
+
+
+
+
+
+
+
快速筛选
+
+
+ {{ filter.label }}
+
+
+
+
+
+
+
搜索历史
+
+
+ {{ history.keyword || '无关键词' }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-system/src/components/BatchOperations.vue b/admin-system/src/components/BatchOperations.vue
new file mode 100644
index 0000000..8cf650c
--- /dev/null
+++ b/admin-system/src/components/BatchOperations.vue
@@ -0,0 +1,455 @@
+
+
+
+
+
+
+ {{ isAllSelected ? '取消全选' : '全选' }}
+
+
+ 清空选择
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ action.label }}
+
+
+
+
+ 批量操作
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ status.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CSV格式
+ Excel格式
+ JSON格式
+
+
+
+
+
+
+
+ {{ field.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-system/src/components/charts/DataStatisticsChart.vue b/admin-system/src/components/charts/DataStatisticsChart.vue
new file mode 100644
index 0000000..0d14198
--- /dev/null
+++ b/admin-system/src/components/charts/DataStatisticsChart.vue
@@ -0,0 +1,342 @@
+
+
+
+
+
+
+ 近7天
+ 近30天
+ 近90天
+ 近一年
+
+
+
+
+
+ 刷新
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-system/src/pages/animals/components/AnimalDetail.vue b/admin-system/src/pages/animals/components/AnimalDetail.vue
new file mode 100644
index 0000000..1ce8ff8
--- /dev/null
+++ b/admin-system/src/pages/animals/components/AnimalDetail.vue
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+ {{ animal.name?.charAt(0) }}
+
+
+
+
+
+
+
+
+ {{ animal.id }}
+
+
+
+
+
+ {{ animal.name }}
+
+
+
+
+
+
{{ animal.type }}
+
+
+
+
+
+ {{ animal.breed }}
+
+
+
+
+
+ {{ animal.age }}岁
+
+
+
+
+
+ {{ animal.gender }}
+
+
+
+
+
+ {{ animal.color }}
+
+
+
+
+
+
+ {{ getStatusText(animal.status) }}
+
+
+
+
+
+
+ {{ animal.health_status }}
+
+
+
+
+
+
+
+
+
+ {{ animal.description || '暂无描述' }}
+
+
+
+
+
+
+ {{ animal.location }}
+
+
+
+
+
+
+
+
+
+ {{ formatDate(animal.createdAt) }}
+
+
+
+
+
+ {{ formatDate(animal.updatedAt) }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-system/src/pages/animals/components/AnimalForm.vue b/admin-system/src/pages/animals/components/AnimalForm.vue
new file mode 100644
index 0000000..72e3117
--- /dev/null
+++ b/admin-system/src/pages/animals/components/AnimalForm.vue
@@ -0,0 +1,362 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 狗
+ 猫
+ 兔子
+ 鸟类
+ 其他
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 雄性
+ 雌性
+ 未知
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 可领养
+ 已领养
+ 不可领养
+
+
+
+
+
+
+ 健康
+ 轻微疾病
+ 需要治疗
+ 康复中
+
+
+
+
+
+
+
+
+
+
+
+
+
![avatar]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-system/src/pages/animals/index.vue b/admin-system/src/pages/animals/index.vue
new file mode 100644
index 0000000..199dd40
--- /dev/null
+++ b/admin-system/src/pages/animals/index.vue
@@ -0,0 +1,716 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
handleSelectionChange(items as Animal[])"
+ @batch-action="(action: string, items: any[], params?: any) => handleBatchAction(action, items as Animal[], params)"
+ />
+
+
+
+
+
+
+
+
+ {{ record.name?.charAt(0) }}
+
+
+
+
+
+
+ {{ getStatusText(record.status) }}
+
+
+
+
+
+ {{ record.type }}
+
+
+
+
+ {{ record.age }}岁
+
+
+
+
+ {{ formatDate(record.createdAt) }}
+
+
+
+
+
+
+ 查看
+
+
+ 编辑
+
+
+
+ 更多
+
+
+
+
+
+ 设为可领养
+
+
+
+ 设为不可领养
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-system/src/pages/statistics/index.vue b/admin-system/src/pages/statistics/index.vue
new file mode 100644
index 0000000..990cc5b
--- /dev/null
+++ b/admin-system/src/pages/statistics/index.vue
@@ -0,0 +1,577 @@
+
+
+
+
+
+
+
+
+
+
+ 导出报表
+
+
+
+
+
+ 刷新全部
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ Math.abs(overviewData.userGrowth) }}%
+
+ 较昨日
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ Math.abs(overviewData.activeGrowth) }}%
+
+ 较昨日
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ Math.abs(overviewData.animalGrowth) }}%
+
+ 较昨日
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ Math.abs(overviewData.revenueGrowth) }}%
+
+ 较昨日
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 用户分布
+ 动物分布
+ 订单分布
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ index + 1 }}
+
+
+
+
+ {{ record.adoption_rate }}%
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ index + 1 }}
+
+
+
+
+ {{ record.nickname?.charAt(0) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-system/src/pages/users/components/UserDetail.vue b/admin-system/src/pages/users/components/UserDetail.vue
new file mode 100644
index 0000000..191032c
--- /dev/null
+++ b/admin-system/src/pages/users/components/UserDetail.vue
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+
+
+ {{ user.id }}
+
+
+
+
+
+ {{ user.username }}
+
+
+
+
+
+ {{ user.nickname || '-' }}
+
+
+
+
+
+ {{ user.email || '-' }}
+
+
+
+
+
+ {{ user.phone || '-' }}
+
+
+
+
+
+
+ {{ getStatusText(user.status) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDate(user.created_at) }}
+
+
+
+
+
+ {{ formatDate(user.updated_at) }}
+
+
+
+
+
+ {{ user.last_login_at ? formatDate(user.last_login_at) : '-' }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-system/src/pages/users/components/UserForm.vue b/admin-system/src/pages/users/components/UserForm.vue
new file mode 100644
index 0000000..a051114
--- /dev/null
+++ b/admin-system/src/pages/users/components/UserForm.vue
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 男
+ 女
+ 未知
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 正常
+ 禁用
+ 待审核
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-system/src/pages/users/index.vue b/admin-system/src/pages/users/index.vue
new file mode 100644
index 0000000..a849030
--- /dev/null
+++ b/admin-system/src/pages/users/index.vue
@@ -0,0 +1,843 @@
+
+
+
+
+
+
+
+ 新增用户
+
+
+
+ 刷新
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ handleSelectionChange(items as User[])"
+ @batch-action="(action: string, items: any[], params?: any) => handleBatchAction(action, items as User[], params)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ record.nickname || record.username }}
+
+
+ VIP
+
+
+
+
+
+
+
+
+
+ {{ getStatusText(record.status) }}
+
+
+
+
+
+
+
+
+ {{ getSourceText(record.register_source) }}
+
+
+
+
+ {{ formatDate(record.created_at) }}
+
+
+
+
+
+
+
+
+
{{ formatDate(record.last_login_at) }}
+
+ {{ record.last_login_ip }}
+
+
+
+ 从未登录
+
+
+
+
+
+
+
+ 查看
+
+
+ 编辑
+
+
+
+ handleMenuAction(key, record)">
+
+
+ 重置密码
+
+
+
+ 发送消息
+
+
+
+ {{ record.status === 'active' ? '禁用' : '启用' }}
+
+
+
+
+ 删除
+
+
+
+
+ 更多
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 通知
+ 警告
+ 推广
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin-system/src/utils/date.ts b/admin-system/src/utils/date.ts
new file mode 100644
index 0000000..81c3e14
--- /dev/null
+++ b/admin-system/src/utils/date.ts
@@ -0,0 +1,166 @@
+import dayjs from 'dayjs'
+import relativeTime from 'dayjs/plugin/relativeTime'
+import utc from 'dayjs/plugin/utc'
+import timezone from 'dayjs/plugin/timezone'
+import 'dayjs/locale/zh-cn'
+
+// 配置dayjs插件
+dayjs.extend(relativeTime)
+dayjs.extend(utc)
+dayjs.extend(timezone)
+dayjs.locale('zh-cn')
+
+/**
+ * 格式化日期
+ * @param date 日期
+ * @param format 格式化字符串,默认为 'YYYY-MM-DD HH:mm:ss'
+ * @returns 格式化后的日期字符串
+ */
+export const formatDate = (date: string | Date | dayjs.Dayjs | null | undefined, format = 'YYYY-MM-DD HH:mm:ss'): string => {
+ if (!date) return ''
+ return dayjs(date).format(format)
+}
+
+/**
+ * 格式化相对时间
+ * @param date 日期
+ * @returns 相对时间字符串,如 "2小时前"
+ */
+export const formatRelativeTime = (date: string | Date | dayjs.Dayjs | null | undefined): string => {
+ if (!date) return ''
+ return dayjs(date).fromNow()
+}
+
+/**
+ * 格式化日期为友好显示
+ * @param date 日期
+ * @returns 友好的日期显示
+ */
+export const formatFriendlyDate = (date: string | Date | dayjs.Dayjs | null | undefined): string => {
+ if (!date) return ''
+ const now = dayjs()
+ const target = dayjs(date)
+ const diffDays = now.diff(target, 'day')
+
+ if (diffDays === 0) {
+ return target.format('HH:mm')
+ } else if (diffDays === 1) {
+ return `昨天 ${target.format('HH:mm')}`
+ } else if (diffDays < 7) {
+ return `${diffDays}天前`
+ } else {
+ return target.format('MM-DD HH:mm')
+ }
+}
+
+/**
+ * 格式化为日期(不包含时间)
+ * @param date 日期字符串或Date对象
+ * @returns 格式化后的日期字符串,如"2024-01-15"
+ */
+export const formatDateOnly = (date: string | Date | null | undefined): string => {
+ return formatDate(date, 'YYYY-MM-DD')
+}
+
+/**
+ * 格式化为时间(不包含日期)
+ * @param date 日期字符串或Date对象
+ * @returns 格式化后的时间字符串,如"14:30:25"
+ */
+export const formatTimeOnly = (date: string | Date | null | undefined): string => {
+ return formatDate(date, 'HH:mm:ss')
+}
+
+/**
+ * 格式化为中文日期时间
+ * @param date 日期字符串或Date对象
+ * @returns 中文格式的日期时间字符串,如"2024年1月15日 14:30"
+ */
+export const formatChineseDateTime = (date: string | Date | null | undefined): string => {
+ return formatDate(date, 'YYYY年M月D日 HH:mm')
+}
+
+/**
+ * 判断是否为今天
+ * @param date 日期
+ * @returns 是否为今天
+ */
+export const isToday = (date: string | Date | dayjs.Dayjs | null | undefined): boolean => {
+ if (!date) return false
+ return dayjs(date).isSame(dayjs(), 'day')
+}
+
+/**
+ * 判断是否为本周
+ * @param date 日期
+ * @returns 是否为本周
+ */
+export const isThisWeek = (date: string | Date | dayjs.Dayjs | null | undefined): boolean => {
+ if (!date) return false
+ return dayjs(date).isSame(dayjs(), 'week')
+}
+
+/**
+ * 判断是否为本月
+ * @param date 日期
+ * @returns 是否为本月
+ */
+export const isThisMonth = (date: string | Date | dayjs.Dayjs | null | undefined): boolean => {
+ if (!date) return false
+ return dayjs(date).isSame(dayjs(), 'month')
+}
+
+/**
+ * 获取时间范围
+ * @param type 时间范围类型
+ * @returns 时间范围数组 [开始时间, 结束时间]
+ */
+export const getTimeRange = (type: 'today' | 'yesterday' | 'week' | 'month' | 'year'): [dayjs.Dayjs, dayjs.Dayjs] => {
+ const now = dayjs()
+
+ switch (type) {
+ case 'today':
+ return [now.startOf('day'), now.endOf('day')]
+ case 'yesterday':
+ const yesterday = now.subtract(1, 'day')
+ return [yesterday.startOf('day'), yesterday.endOf('day')]
+ case 'week':
+ return [now.startOf('week'), now.endOf('week')]
+ case 'month':
+ return [now.startOf('month'), now.endOf('month')]
+ case 'year':
+ return [now.startOf('year'), now.endOf('year')]
+ default:
+ return [now.startOf('day'), now.endOf('day')]
+ }
+}
+
+/**
+ * 转换时区
+ * @param date 日期
+ * @param timezone 目标时区
+ * @returns 转换后的日期
+ */
+export const convertTimezone = (date: string | Date | dayjs.Dayjs, timezone: string): dayjs.Dayjs => {
+ return dayjs(date).tz(timezone)
+}
+
+/**
+ * 获取当前时区
+ * @returns 当前时区
+ */
+export const getCurrentTimezone = (): string => {
+ return dayjs.tz.guess()
+}
+
+export default {
+ formatDate,
+ formatRelativeTime,
+ formatFriendlyDate,
+ isToday,
+ isThisWeek,
+ isThisMonth,
+ getTimeRange,
+ convertTimezone,
+ getCurrentTimezone
+}
\ No newline at end of file
diff --git a/backend/scripts/animal_claims_table.sql b/backend/scripts/animal_claims_table.sql
new file mode 100644
index 0000000..1dff018
--- /dev/null
+++ b/backend/scripts/animal_claims_table.sql
@@ -0,0 +1,236 @@
+-- 动物认领申请表
+CREATE TABLE IF NOT EXISTS animal_claims (
+ id INT PRIMARY KEY AUTO_INCREMENT COMMENT '认领申请ID',
+ claim_no VARCHAR(32) NOT NULL UNIQUE COMMENT '认领订单号',
+ animal_id INT NOT NULL COMMENT '动物ID',
+ user_id INT NOT NULL COMMENT '用户ID',
+ claim_reason TEXT COMMENT '认领理由',
+ claim_duration INT NOT NULL DEFAULT 12 COMMENT '认领时长(月)',
+ total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '总金额',
+ contact_info VARCHAR(500) NOT NULL COMMENT '联系方式',
+ status ENUM('pending', 'approved', 'rejected', 'cancelled') NOT NULL DEFAULT 'pending' COMMENT '申请状态',
+ start_date DATETIME NULL COMMENT '开始日期',
+ end_date DATETIME NULL COMMENT '结束日期',
+ reviewed_by INT NULL COMMENT '审核人ID',
+ review_remark TEXT COMMENT '审核备注',
+ reviewed_at DATETIME NULL COMMENT '审核时间',
+ approved_at DATETIME NULL COMMENT '通过时间',
+ cancelled_at DATETIME NULL COMMENT '取消时间',
+ cancel_reason VARCHAR(500) NULL COMMENT '取消原因',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME NULL COMMENT '删除时间',
+
+ -- 外键约束
+ FOREIGN KEY (animal_id) REFERENCES animals(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL,
+
+ -- 索引
+ INDEX idx_animal_id (animal_id),
+ INDEX idx_user_id (user_id),
+ INDEX idx_status (status),
+ INDEX idx_created_at (created_at),
+ INDEX idx_claim_no (claim_no),
+ INDEX idx_deleted_at (deleted_at),
+
+ -- 唯一约束:同一用户对同一动物在同一时间只能有一个有效申请
+ UNIQUE KEY uk_user_animal_active (user_id, animal_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='动物认领申请表';
+
+-- 动物认领续期记录表
+CREATE TABLE IF NOT EXISTS animal_claim_renewals (
+ id INT PRIMARY KEY AUTO_INCREMENT COMMENT '续期记录ID',
+ claim_id INT NOT NULL COMMENT '认领申请ID',
+ duration INT NOT NULL COMMENT '续期时长(月)',
+ amount DECIMAL(10,2) NOT NULL COMMENT '续期金额',
+ payment_method ENUM('wechat', 'alipay', 'bank_transfer') NOT NULL COMMENT '支付方式',
+ status ENUM('pending', 'paid', 'cancelled') NOT NULL DEFAULT 'pending' COMMENT '续期状态',
+ payment_no VARCHAR(64) NULL COMMENT '支付订单号',
+ paid_at DATETIME NULL COMMENT '支付时间',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+ -- 外键约束
+ FOREIGN KEY (claim_id) REFERENCES animal_claims(id) ON DELETE CASCADE,
+
+ -- 索引
+ INDEX idx_claim_id (claim_id),
+ INDEX idx_status (status),
+ INDEX idx_created_at (created_at),
+ INDEX idx_payment_no (payment_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='动物认领续期记录表';
+
+-- 插入测试数据
+INSERT INTO animal_claims (
+ claim_no, animal_id, user_id, claim_reason, claim_duration,
+ total_amount, contact_info, status, created_at
+) VALUES
+(
+ 'CLAIM20241201001', 1, 2, '我很喜欢这只小狗,希望能够认领它', 12,
+ 1200.00, '手机:13800138001,微信:user001', 'pending', '2024-12-01 10:00:00'
+),
+(
+ 'CLAIM20241201002', 2, 3, '想要认领这只小猫,会好好照顾它', 6,
+ 600.00, '手机:13800138002,QQ:123456789', 'approved', '2024-12-01 11:00:00'
+),
+(
+ 'CLAIM20241201003', 3, 4, '希望认领这只兔子,家里有足够的空间', 24,
+ 2400.00, '手机:13800138003,邮箱:user003@example.com', 'rejected', '2024-12-01 12:00:00'
+);
+
+-- 更新已通过的认领申请的时间信息
+UPDATE animal_claims
+SET
+ start_date = '2024-12-01 11:30:00',
+ end_date = '2025-06-01 11:30:00',
+ reviewed_by = 1,
+ review_remark = '申请材料完整,同意认领',
+ reviewed_at = '2024-12-01 11:30:00',
+ approved_at = '2024-12-01 11:30:00'
+WHERE claim_no = 'CLAIM20241201002';
+
+-- 更新被拒绝的认领申请的审核信息
+UPDATE animal_claims
+SET
+ reviewed_by = 1,
+ review_remark = '认领时长过长,建议缩短认领期限后重新申请',
+ reviewed_at = '2024-12-01 12:30:00'
+WHERE claim_no = 'CLAIM20241201003';
+
+-- 插入续期记录测试数据
+INSERT INTO animal_claim_renewals (
+ claim_id, duration, amount, payment_method, status, created_at
+) VALUES
+(
+ 2, 6, 600.00, 'wechat', 'pending', '2024-12-01 15:00:00'
+);
+
+-- 创建视图:认领申请详情视图
+CREATE OR REPLACE VIEW v_animal_claim_details AS
+SELECT
+ ac.id,
+ ac.claim_no,
+ ac.animal_id,
+ a.name as animal_name,
+ a.type as animal_type,
+ a.breed as animal_breed,
+ a.age as animal_age,
+ a.gender as animal_gender,
+ a.image as animal_image,
+ a.price as animal_price,
+ ac.user_id,
+ u.username,
+ u.phone as user_phone,
+ u.email as user_email,
+ ac.claim_reason,
+ ac.claim_duration,
+ ac.total_amount,
+ ac.contact_info,
+ ac.status,
+ ac.start_date,
+ ac.end_date,
+ ac.reviewed_by,
+ reviewer.username as reviewer_name,
+ ac.review_remark,
+ ac.reviewed_at,
+ ac.approved_at,
+ ac.cancelled_at,
+ ac.cancel_reason,
+ ac.created_at,
+ ac.updated_at,
+ -- 计算剩余天数
+ CASE
+ WHEN ac.status = 'approved' AND ac.end_date > NOW()
+ THEN DATEDIFF(ac.end_date, NOW())
+ ELSE 0
+ END as remaining_days,
+ -- 是否即将到期(30天内)
+ CASE
+ WHEN ac.status = 'approved' AND ac.end_date > NOW() AND DATEDIFF(ac.end_date, NOW()) <= 30
+ THEN 1
+ ELSE 0
+ END as is_expiring_soon
+FROM animal_claims ac
+LEFT JOIN animals a ON ac.animal_id = a.id
+LEFT JOIN users u ON ac.user_id = u.id
+LEFT JOIN users reviewer ON ac.reviewed_by = reviewer.id
+WHERE ac.deleted_at IS NULL;
+
+-- 创建触发器:认领申请通过时更新动物状态
+DELIMITER //
+CREATE TRIGGER tr_animal_claim_approved
+AFTER UPDATE ON animal_claims
+FOR EACH ROW
+BEGIN
+ -- 如果认领申请从其他状态变为已通过
+ IF OLD.status != 'approved' AND NEW.status = 'approved' THEN
+ UPDATE animals SET status = 'claimed', claim_count = claim_count + 1 WHERE id = NEW.animal_id;
+ END IF;
+
+ -- 如果认领申请从已通过变为其他状态
+ IF OLD.status = 'approved' AND NEW.status != 'approved' THEN
+ UPDATE animals SET status = 'available' WHERE id = NEW.animal_id;
+ END IF;
+END//
+DELIMITER ;
+
+-- 创建存储过程:批量处理过期的认领申请
+DELIMITER //
+CREATE PROCEDURE sp_handle_expired_claims()
+BEGIN
+ DECLARE done INT DEFAULT FALSE;
+ DECLARE claim_id INT;
+ DECLARE animal_id INT;
+
+ -- 声明游标
+ DECLARE expired_cursor CURSOR FOR
+ SELECT id, animal_id
+ FROM animal_claims
+ WHERE status = 'approved'
+ AND end_date < NOW()
+ AND deleted_at IS NULL;
+
+ DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
+
+ -- 开始事务
+ START TRANSACTION;
+
+ -- 打开游标
+ OPEN expired_cursor;
+
+ read_loop: LOOP
+ FETCH expired_cursor INTO claim_id, animal_id;
+ IF done THEN
+ LEAVE read_loop;
+ END IF;
+
+ -- 更新认领申请状态为已过期
+ UPDATE animal_claims
+ SET status = 'expired', updated_at = NOW()
+ WHERE id = claim_id;
+
+ -- 更新动物状态为可认领
+ UPDATE animals
+ SET status = 'available', updated_at = NOW()
+ WHERE id = animal_id;
+
+ END LOOP;
+
+ -- 关闭游标
+ CLOSE expired_cursor;
+
+ -- 提交事务
+ COMMIT;
+
+ -- 返回处理的记录数
+ SELECT ROW_COUNT() as processed_count;
+END//
+DELIMITER ;
+
+-- 创建事件:每天自动处理过期的认领申请
+CREATE EVENT IF NOT EXISTS ev_handle_expired_claims
+ON SCHEDULE EVERY 1 DAY
+STARTS '2024-12-01 02:00:00'
+DO
+ CALL sp_handle_expired_claims();
\ No newline at end of file
diff --git a/backend/scripts/payments_table.sql b/backend/scripts/payments_table.sql
new file mode 100644
index 0000000..a0262c3
--- /dev/null
+++ b/backend/scripts/payments_table.sql
@@ -0,0 +1,70 @@
+-- 支付订单表
+CREATE TABLE IF NOT EXISTS `payments` (
+ `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '支付订单ID',
+ `payment_no` varchar(64) NOT NULL COMMENT '支付订单号',
+ `order_id` int(11) NOT NULL COMMENT '关联订单ID',
+ `user_id` int(11) NOT NULL COMMENT '用户ID',
+ `amount` decimal(10,2) NOT NULL COMMENT '支付金额',
+ `paid_amount` decimal(10,2) DEFAULT NULL COMMENT '实际支付金额',
+ `payment_method` enum('wechat','alipay','balance') NOT NULL COMMENT '支付方式:wechat-微信支付,alipay-支付宝,balance-余额支付',
+ `status` enum('pending','paid','failed','refunded','cancelled') NOT NULL DEFAULT 'pending' COMMENT '支付状态:pending-待支付,paid-已支付,failed-支付失败,refunded-已退款,cancelled-已取消',
+ `transaction_id` varchar(128) DEFAULT NULL COMMENT '第三方交易号',
+ `return_url` varchar(255) DEFAULT NULL COMMENT '支付成功回调地址',
+ `notify_url` varchar(255) DEFAULT NULL COMMENT '异步通知地址',
+ `paid_at` datetime DEFAULT NULL COMMENT '支付时间',
+ `failure_reason` varchar(255) DEFAULT NULL COMMENT '失败原因',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `deleted_at` datetime DEFAULT NULL COMMENT '删除时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_payment_no` (`payment_no`),
+ KEY `idx_order_id` (`order_id`),
+ KEY `idx_user_id` (`user_id`),
+ KEY `idx_status` (`status`),
+ KEY `idx_payment_method` (`payment_method`),
+ KEY `idx_transaction_id` (`transaction_id`),
+ KEY `idx_created_at` (`created_at`),
+ KEY `idx_deleted_at` (`deleted_at`),
+ CONSTRAINT `fk_payments_order_id` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_payments_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付订单表';
+
+-- 退款记录表
+CREATE TABLE IF NOT EXISTS `refunds` (
+ `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '退款ID',
+ `refund_no` varchar(64) NOT NULL COMMENT '退款订单号',
+ `payment_id` int(11) NOT NULL COMMENT '支付订单ID',
+ `user_id` int(11) NOT NULL COMMENT '用户ID',
+ `refund_amount` decimal(10,2) NOT NULL COMMENT '退款金额',
+ `refund_reason` varchar(500) NOT NULL COMMENT '退款原因',
+ `status` enum('pending','approved','rejected','completed') NOT NULL DEFAULT 'pending' COMMENT '退款状态:pending-待处理,approved-已同意,rejected-已拒绝,completed-已完成',
+ `processed_by` int(11) DEFAULT NULL COMMENT '处理人ID',
+ `process_remark` varchar(500) DEFAULT NULL COMMENT '处理备注',
+ `refund_transaction_id` varchar(128) DEFAULT NULL COMMENT '退款交易号',
+ `processed_at` datetime DEFAULT NULL COMMENT '处理时间',
+ `refunded_at` datetime DEFAULT NULL COMMENT '退款完成时间',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `deleted_at` datetime DEFAULT NULL COMMENT '删除时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_refund_no` (`refund_no`),
+ KEY `idx_payment_id` (`payment_id`),
+ KEY `idx_user_id` (`user_id`),
+ KEY `idx_status` (`status`),
+ KEY `idx_processed_by` (`processed_by`),
+ KEY `idx_created_at` (`created_at`),
+ KEY `idx_deleted_at` (`deleted_at`),
+ CONSTRAINT `fk_refunds_payment_id` FOREIGN KEY (`payment_id`) REFERENCES `payments` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_refunds_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_refunds_processed_by` FOREIGN KEY (`processed_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退款记录表';
+
+-- 插入示例数据(可选)
+-- INSERT INTO `payments` (`payment_no`, `order_id`, `user_id`, `amount`, `payment_method`, `status`) VALUES
+-- ('PAY202401010001', 1, 1, 299.00, 'wechat', 'pending'),
+-- ('PAY202401010002', 2, 2, 199.00, 'alipay', 'paid');
+
+-- 创建索引优化查询性能
+CREATE INDEX `idx_payments_user_status` ON `payments` (`user_id`, `status`);
+CREATE INDEX `idx_payments_method_status` ON `payments` (`payment_method`, `status`);
+CREATE INDEX `idx_refunds_user_status` ON `refunds` (`user_id`, `status`);
\ No newline at end of file
diff --git a/backend/src/app.js b/backend/src/app.js
index 444db6e..8f181ff 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -15,7 +15,7 @@ const { globalErrorHandler, notFound } = require('./utils/errors');
// 检查是否为无数据库模式
const NO_DB_MODE = process.env.NO_DB_MODE === 'true';
-let authRoutes, userRoutes, travelRoutes, animalRoutes, orderRoutes, adminRoutes;
+let authRoutes, userRoutes, travelRoutes, animalRoutes, orderRoutes, adminRoutes, travelRegistrationRoutes;
// 路由导入 - 根据是否为无数据库模式决定是否导入实际路由
if (NO_DB_MODE) {
@@ -28,6 +28,9 @@ if (NO_DB_MODE) {
animalRoutes = require('./routes/animal');
orderRoutes = require('./routes/order');
adminRoutes = require('./routes/admin'); // 新增管理员路由
+ travelRegistrationRoutes = require('./routes/travelRegistration'); // 旅行报名路由
+ paymentRoutes = require('./routes/payment');
+ animalClaimRoutes = require('./routes/animalClaim'); // 动物认领路由
}
const app = express();
@@ -177,6 +180,27 @@ if (NO_DB_MODE) {
});
});
+ app.use('/api/v1/travel-registration', (req, res) => {
+ res.status(503).json({
+ success: false,
+ message: '当前为无数据库模式,旅行报名功能不可用'
+ });
+ });
+
+ app.use('/api/v1/payments', (req, res) => {
+ res.status(503).json({
+ success: false,
+ message: '当前为无数据库模式,支付功能不可用'
+ });
+ });
+
+ app.use('/api/v1/animal-claims', (req, res) => {
+ res.status(503).json({
+ success: false,
+ message: '当前为无数据库模式,动物认领功能不可用'
+ });
+ });
+
app.use('/api/v1/admin', (req, res) => {
res.status(503).json({
success: false,
@@ -190,8 +214,13 @@ if (NO_DB_MODE) {
app.use('/api/v1/travel', travelRoutes);
app.use('/api/v1/animals', animalRoutes);
app.use('/api/v1/orders', orderRoutes);
+ app.use('/api/v1/payments', paymentRoutes);
+ // 动物认领路由
+ app.use('/api/v1/animal-claims', animalClaimRoutes);
// 管理员路由
app.use('/api/v1/admin', adminRoutes);
+ // 旅行报名路由
+ app.use('/api/v1/travel-registration', travelRegistrationRoutes);
}
// 404处理
diff --git a/backend/src/controllers/admin/animalManagement.js b/backend/src/controllers/admin/animalManagement.js
new file mode 100644
index 0000000..64917e2
--- /dev/null
+++ b/backend/src/controllers/admin/animalManagement.js
@@ -0,0 +1,431 @@
+const Animal = require('../../models/Animal');
+const AnimalClaim = require('../../models/AnimalClaim');
+const { validationResult } = require('express-validator');
+
+/**
+ * 管理员动物管理控制器
+ * @class AnimalManagementController
+ */
+class AnimalManagementController {
+ /**
+ * 获取动物列表
+ * @param {Object} req - Express请求对象
+ * @param {Object} res - Express响应对象
+ */
+ static async getAnimalList(req, res) {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({
+ success: false,
+ message: '参数验证失败',
+ errors: errors.array()
+ });
+ }
+
+ const {
+ page = 1,
+ limit = 10,
+ keyword,
+ species,
+ status,
+ merchant_id,
+ start_date,
+ end_date,
+ sort_by = 'created_at',
+ sort_order = 'desc'
+ } = req.query;
+
+ const offset = (page - 1) * limit;
+
+ // 构建查询条件
+ let whereClause = '';
+ const params = [];
+
+ if (keyword) {
+ whereClause += ' AND (a.name LIKE ? OR a.description LIKE ?)';
+ params.push(`%${keyword}%`, `%${keyword}%`);
+ }
+
+ if (species) {
+ whereClause += ' AND a.species = ?';
+ params.push(species);
+ }
+
+ if (status) {
+ whereClause += ' AND a.status = ?';
+ params.push(status);
+ }
+
+ if (merchant_id) {
+ whereClause += ' AND a.merchant_id = ?';
+ params.push(merchant_id);
+ }
+
+ if (start_date) {
+ whereClause += ' AND DATE(a.created_at) >= ?';
+ params.push(start_date);
+ }
+
+ if (end_date) {
+ whereClause += ' AND DATE(a.created_at) <= ?';
+ params.push(end_date);
+ }
+
+ // 获取动物列表
+ const animals = await Animal.getAnimalListWithMerchant({
+ whereClause,
+ params,
+ sortBy: sort_by,
+ sortOrder: sort_order,
+ limit: parseInt(limit),
+ offset
+ });
+
+ // 获取总数
+ const totalCount = await Animal.getAnimalCount({
+ whereClause,
+ params
+ });
+
+ res.json({
+ success: true,
+ message: '获取成功',
+ data: {
+ animals,
+ pagination: {
+ current_page: parseInt(page),
+ per_page: parseInt(limit),
+ total: totalCount,
+ total_pages: Math.ceil(totalCount / limit)
+ }
+ }
+ });
+ } catch (error) {
+ console.error('获取动物列表失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取动物列表失败',
+ error: error.message
+ });
+ }
+ }
+
+ /**
+ * 获取动物详情
+ * @param {Object} req - Express请求对象
+ * @param {Object} res - Express响应对象
+ */
+ static async getAnimalDetail(req, res) {
+ try {
+ const { animal_id } = req.params;
+
+ // 获取动物详情
+ const animal = await Animal.getAnimalDetailWithMerchant(animal_id);
+ if (!animal) {
+ return res.status(404).json({
+ success: false,
+ message: '动物不存在'
+ });
+ }
+
+ // 获取认领统计
+ const claimStats = await AnimalClaim.getAnimalClaimStats(animal_id);
+
+ // 获取最近的认领记录
+ const recentClaims = await AnimalClaim.getAnimalClaimList(animal_id, {
+ limit: 5,
+ offset: 0
+ });
+
+ res.json({
+ success: true,
+ message: '获取成功',
+ data: {
+ animal,
+ claimStats,
+ recentClaims
+ }
+ });
+ } catch (error) {
+ console.error('获取动物详情失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取动物详情失败',
+ error: error.message
+ });
+ }
+ }
+
+ /**
+ * 更新动物状态
+ * @param {Object} req - Express请求对象
+ * @param {Object} res - Express响应对象
+ */
+ static async updateAnimalStatus(req, res) {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({
+ success: false,
+ message: '参数验证失败',
+ errors: errors.array()
+ });
+ }
+
+ const { animal_id } = req.params;
+ const { status, reason } = req.body;
+ const adminId = req.user.id;
+
+ // 检查动物是否存在
+ const animal = await Animal.findById(animal_id);
+ if (!animal) {
+ return res.status(404).json({
+ success: false,
+ message: '动物不存在'
+ });
+ }
+
+ // 更新动物状态
+ await Animal.updateAnimalStatus(animal_id, status, adminId, reason);
+
+ res.json({
+ success: true,
+ message: '动物状态更新成功'
+ });
+ } catch (error) {
+ console.error('更新动物状态失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '更新动物状态失败',
+ error: error.message
+ });
+ }
+ }
+
+ /**
+ * 批量更新动物状态
+ * @param {Object} req - Express请求对象
+ * @param {Object} res - Express响应对象
+ */
+ static async batchUpdateAnimalStatus(req, res) {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({
+ success: false,
+ message: '参数验证失败',
+ errors: errors.array()
+ });
+ }
+
+ const { animal_ids, status, reason } = req.body;
+ const adminId = req.user.id;
+
+ // 批量更新动物状态
+ const results = await Animal.batchUpdateAnimalStatus(animal_ids, status, adminId, reason);
+
+ res.json({
+ success: true,
+ message: '批量更新动物状态成功',
+ data: {
+ updated_count: results.affectedRows
+ }
+ });
+ } catch (error) {
+ console.error('批量更新动物状态失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '批量更新动物状态失败',
+ error: error.message
+ });
+ }
+ }
+
+ /**
+ * 获取动物统计信息
+ * @param {Object} req - Express请求对象
+ * @param {Object} res - Express响应对象
+ */
+ static async getAnimalStatistics(req, res) {
+ try {
+ // 获取动物总体统计
+ const totalStats = await Animal.getAnimalTotalStats();
+
+ // 获取按物种分类的统计
+ const speciesStats = await Animal.getAnimalStatsBySpecies();
+
+ // 获取按状态分类的统计
+ const statusStats = await Animal.getAnimalStatsByStatus();
+
+ // 获取按商家分类的统计
+ const merchantStats = await Animal.getAnimalStatsByMerchant();
+
+ // 获取认领统计
+ const claimStats = await AnimalClaim.getClaimTotalStats();
+
+ // 获取月度趋势数据
+ const monthlyTrend = await Animal.getAnimalMonthlyTrend();
+
+ res.json({
+ success: true,
+ message: '获取成功',
+ data: {
+ totalStats,
+ speciesStats,
+ statusStats,
+ merchantStats,
+ claimStats,
+ monthlyTrend
+ }
+ });
+ } catch (error) {
+ console.error('获取动物统计信息失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取动物统计信息失败',
+ error: error.message
+ });
+ }
+ }
+
+ /**
+ * 导出动物数据
+ * @param {Object} req - Express请求对象
+ * @param {Object} res - Express响应对象
+ */
+ static async exportAnimalData(req, res) {
+ try {
+ const {
+ format = 'csv',
+ keyword,
+ species,
+ status,
+ merchant_id,
+ start_date,
+ end_date
+ } = req.query;
+
+ // 构建查询条件
+ let whereClause = '';
+ const params = [];
+
+ if (keyword) {
+ whereClause += ' AND (a.name LIKE ? OR a.description LIKE ?)';
+ params.push(`%${keyword}%`, `%${keyword}%`);
+ }
+
+ if (species) {
+ whereClause += ' AND a.species = ?';
+ params.push(species);
+ }
+
+ if (status) {
+ whereClause += ' AND a.status = ?';
+ params.push(status);
+ }
+
+ if (merchant_id) {
+ whereClause += ' AND a.merchant_id = ?';
+ params.push(merchant_id);
+ }
+
+ if (start_date) {
+ whereClause += ' AND DATE(a.created_at) >= ?';
+ params.push(start_date);
+ }
+
+ if (end_date) {
+ whereClause += ' AND DATE(a.created_at) <= ?';
+ params.push(end_date);
+ }
+
+ // 获取导出数据
+ const animals = await Animal.getAnimalExportData({
+ whereClause,
+ params
+ });
+
+ if (format === 'csv') {
+ // 生成CSV格式
+ const csvHeader = 'ID,名称,物种,品种,年龄,性别,价格,状态,商家名称,创建时间\n';
+ const csvData = animals.map(animal =>
+ `${animal.id},"${animal.name}","${animal.species}","${animal.breed || ''}",${animal.age || ''},"${animal.gender || ''}",${animal.price},"${animal.status}","${animal.merchant_name}","${animal.created_at}"`
+ ).join('\n');
+
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="animals_${Date.now()}.csv"`);
+ res.send('\ufeff' + csvHeader + csvData); // 添加BOM以支持中文
+ } else {
+ // 返回JSON格式
+ res.json({
+ success: true,
+ message: '导出成功',
+ data: {
+ animals,
+ export_time: new Date().toISOString(),
+ total_count: animals.length
+ }
+ });
+ }
+ } catch (error) {
+ console.error('导出动物数据失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '导出动物数据失败',
+ error: error.message
+ });
+ }
+ }
+
+ /**
+ * 获取动物认领记录
+ * @param {Object} req - Express请求对象
+ * @param {Object} res - Express响应对象
+ */
+ static async getAnimalClaimRecords(req, res) {
+ try {
+ const { animal_id } = req.params;
+ const {
+ page = 1,
+ limit = 10,
+ status
+ } = req.query;
+
+ const offset = (page - 1) * limit;
+
+ // 获取认领记录
+ const claims = await AnimalClaim.getAnimalClaimList(animal_id, {
+ status,
+ limit: parseInt(limit),
+ offset
+ });
+
+ // 获取总数
+ const totalCount = await AnimalClaim.getAnimalClaimCount(animal_id, { status });
+
+ res.json({
+ success: true,
+ message: '获取成功',
+ data: {
+ claims,
+ pagination: {
+ current_page: parseInt(page),
+ per_page: parseInt(limit),
+ total: totalCount,
+ total_pages: Math.ceil(totalCount / limit)
+ }
+ }
+ });
+ } catch (error) {
+ console.error('获取动物认领记录失败:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取动物认领记录失败',
+ error: error.message
+ });
+ }
+ }
+}
+
+module.exports = AnimalManagementController;
\ No newline at end of file
diff --git a/backend/src/controllers/admin/dataStatistics.js b/backend/src/controllers/admin/dataStatistics.js
new file mode 100644
index 0000000..1c940c1
--- /dev/null
+++ b/backend/src/controllers/admin/dataStatistics.js
@@ -0,0 +1,609 @@
+// 管理员数据统计控制器
+const { query } = require('../../config/database');
+
+/**
+ * 获取系统概览统计
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.getSystemOverview = async (req, res, next) => {
+ try {
+ // 用户统计
+ const userStatsSql = `
+ SELECT
+ COUNT(*) as total_users,
+ COUNT(CASE WHEN status = 'active' THEN 1 END) as active_users,
+ COUNT(CASE WHEN DATE(created_at) = CURDATE() THEN 1 END) as new_users_today,
+ COUNT(CASE WHEN DATE(created_at) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN 1 END) as new_users_week
+ FROM users
+ `;
+ const userStats = await query(userStatsSql);
+
+ // 旅行统计
+ const travelStatsSql = `
+ SELECT
+ COUNT(*) as total_travels,
+ COUNT(CASE WHEN status = 'published' THEN 1 END) as published_travels,
+ COUNT(CASE WHEN DATE(created_at) = CURDATE() THEN 1 END) as new_travels_today,
+ COUNT(CASE WHEN DATE(created_at) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN 1 END) as new_travels_week
+ FROM travels
+ `;
+ const travelStats = await query(travelStatsSql);
+
+ // 动物统计
+ const animalStatsSql = `
+ SELECT
+ COUNT(*) as total_animals,
+ COUNT(CASE WHEN status = 'available' THEN 1 END) as available_animals,
+ COUNT(CASE WHEN status = 'claimed' THEN 1 END) as claimed_animals
+ FROM animals
+ `;
+ const animalStats = await query(animalStatsSql);
+
+ // 认领统计
+ const claimStatsSql = `
+ SELECT
+ COUNT(*) as total_claims,
+ COUNT(CASE WHEN status = 'approved' THEN 1 END) as approved_claims,
+ COUNT(CASE WHEN DATE(created_at) = CURDATE() THEN 1 END) as new_claims_today,
+ COUNT(CASE WHEN DATE(created_at) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN 1 END) as new_claims_week
+ FROM animal_claims
+ `;
+ const claimStats = await query(claimStatsSql);
+
+ // 订单统计
+ const orderStatsSql = `
+ SELECT
+ COUNT(*) as total_orders,
+ COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_orders,
+ COALESCE(SUM(CASE WHEN status = 'completed' THEN amount END), 0) as total_revenue,
+ COUNT(CASE WHEN DATE(created_at) = CURDATE() THEN 1 END) as new_orders_today
+ FROM orders
+ `;
+ const orderStats = await query(orderStatsSql);
+
+ // 推广统计
+ const promotionStatsSql = `
+ SELECT
+ COUNT(DISTINCT user_id) as total_promoters,
+ COALESCE(SUM(commission_amount), 0) as total_commission,
+ COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending_withdrawals
+ FROM promotion_records
+ `;
+ const promotionStats = await query(promotionStatsSql);
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '获取成功',
+ data: {
+ users: userStats[0],
+ travels: travelStats[0],
+ animals: animalStats[0],
+ claims: claimStats[0],
+ orders: orderStats[0],
+ promotions: promotionStats[0]
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 获取用户增长趋势
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.getUserGrowthTrend = async (req, res, next) => {
+ try {
+ const { period = '30d' } = req.query;
+
+ let days;
+ switch (period) {
+ case '7d':
+ days = 7;
+ break;
+ case '90d':
+ days = 90;
+ break;
+ case '365d':
+ days = 365;
+ break;
+ default:
+ days = 30;
+ }
+
+ const trendSql = `
+ SELECT
+ DATE(created_at) as date,
+ COUNT(*) as new_users,
+ COUNT(CASE WHEN user_type = 'farmer' THEN 1 END) as new_farmers,
+ COUNT(CASE WHEN user_type = 'merchant' THEN 1 END) as new_merchants
+ FROM users
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
+ GROUP BY DATE(created_at)
+ ORDER BY date ASC
+ `;
+
+ const trendData = await query(trendSql);
+
+ // 计算累计用户数
+ const cumulativeSql = `
+ SELECT COUNT(*) as cumulative_users
+ FROM users
+ WHERE created_at < DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
+ `;
+ const cumulativeResult = await query(cumulativeSql);
+ let cumulativeUsers = cumulativeResult[0].cumulative_users;
+
+ const enrichedTrendData = trendData.map(item => {
+ cumulativeUsers += item.new_users;
+ return {
+ ...item,
+ cumulative_users: cumulativeUsers
+ };
+ });
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '获取成功',
+ data: {
+ period,
+ trendData: enrichedTrendData
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 获取业务数据统计
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.getBusinessStatistics = async (req, res, next) => {
+ try {
+ const { period = '30d' } = req.query;
+
+ let days;
+ switch (period) {
+ case '7d':
+ days = 7;
+ break;
+ case '90d':
+ days = 90;
+ break;
+ default:
+ days = 30;
+ }
+
+ // 旅行数据统计
+ const travelStatsSql = `
+ SELECT
+ DATE(created_at) as date,
+ COUNT(*) as new_travels,
+ COUNT(CASE WHEN status = 'published' THEN 1 END) as published_travels,
+ COUNT(CASE WHEN status = 'matched' THEN 1 END) as matched_travels
+ FROM travels
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
+ GROUP BY DATE(created_at)
+ ORDER BY date ASC
+ `;
+ const travelStats = await query(travelStatsSql);
+
+ // 认领数据统计
+ const claimStatsSql = `
+ SELECT
+ DATE(created_at) as date,
+ COUNT(*) as new_claims,
+ COUNT(CASE WHEN status = 'approved' THEN 1 END) as approved_claims,
+ COUNT(CASE WHEN status = 'rejected' THEN 1 END) as rejected_claims
+ FROM animal_claims
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
+ GROUP BY DATE(created_at)
+ ORDER BY date ASC
+ `;
+ const claimStats = await query(claimStatsSql);
+
+ // 订单数据统计
+ const orderStatsSql = `
+ SELECT
+ DATE(created_at) as date,
+ COUNT(*) as new_orders,
+ COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_orders,
+ COALESCE(SUM(CASE WHEN status = 'completed' THEN amount END), 0) as daily_revenue
+ FROM orders
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
+ GROUP BY DATE(created_at)
+ ORDER BY date ASC
+ `;
+ const orderStats = await query(orderStatsSql);
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '获取成功',
+ data: {
+ period,
+ travelStats,
+ claimStats,
+ orderStats
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 获取地域分布统计
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.getGeographicDistribution = async (req, res, next) => {
+ try {
+ // 用户地域分布
+ const userDistributionSql = `
+ SELECT
+ province,
+ city,
+ COUNT(*) as user_count
+ FROM users
+ WHERE province IS NOT NULL AND city IS NOT NULL
+ GROUP BY province, city
+ ORDER BY user_count DESC
+ LIMIT 50
+ `;
+ const userDistribution = await query(userDistributionSql);
+
+ // 省份统计
+ const provinceStatsSql = `
+ SELECT
+ province,
+ COUNT(*) as user_count,
+ COUNT(CASE WHEN user_type = 'farmer' THEN 1 END) as farmer_count,
+ COUNT(CASE WHEN user_type = 'merchant' THEN 1 END) as merchant_count
+ FROM users
+ WHERE province IS NOT NULL
+ GROUP BY province
+ ORDER BY user_count DESC
+ `;
+ const provinceStats = await query(provinceStatsSql);
+
+ // 旅行目的地统计
+ const destinationStatsSql = `
+ SELECT
+ destination,
+ COUNT(*) as travel_count
+ FROM travels
+ WHERE destination IS NOT NULL
+ GROUP BY destination
+ ORDER BY travel_count DESC
+ LIMIT 20
+ `;
+ const destinationStats = await query(destinationStatsSql);
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '获取成功',
+ data: {
+ userDistribution,
+ provinceStats,
+ destinationStats
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 获取用户行为分析
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.getUserBehaviorAnalysis = async (req, res, next) => {
+ try {
+ // 用户活跃度分析
+ const activitySql = `
+ SELECT
+ CASE
+ WHEN last_login_at >= DATE_SUB(NOW(), INTERVAL 1 DAY) THEN '今日活跃'
+ WHEN last_login_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN '本周活跃'
+ WHEN last_login_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN '本月活跃'
+ ELSE '不活跃'
+ END as activity_level,
+ COUNT(*) as user_count
+ FROM users
+ WHERE last_login_at IS NOT NULL
+ GROUP BY activity_level
+ `;
+ const activityStats = await query(activitySql);
+
+ // 用户等级分布
+ const levelDistributionSql = `
+ SELECT
+ level,
+ COUNT(*) as user_count,
+ AVG(points) as avg_points,
+ AVG(travel_count) as avg_travel_count,
+ AVG(animal_claim_count) as avg_claim_count
+ FROM users
+ GROUP BY level
+ ORDER BY
+ CASE level
+ WHEN 'bronze' THEN 1
+ WHEN 'silver' THEN 2
+ WHEN 'gold' THEN 3
+ WHEN 'platinum' THEN 4
+ END
+ `;
+ const levelDistribution = await query(levelDistributionSql);
+
+ // 用户行为偏好
+ const behaviorSql = `
+ SELECT
+ 'travel_focused' as behavior_type,
+ COUNT(*) as user_count
+ FROM users
+ WHERE travel_count > animal_claim_count AND travel_count > 0
+
+ UNION ALL
+
+ SELECT
+ 'animal_focused' as behavior_type,
+ COUNT(*) as user_count
+ FROM users
+ WHERE animal_claim_count > travel_count AND animal_claim_count > 0
+
+ UNION ALL
+
+ SELECT
+ 'balanced' as behavior_type,
+ COUNT(*) as user_count
+ FROM users
+ WHERE travel_count = animal_claim_count AND travel_count > 0
+
+ UNION ALL
+
+ SELECT
+ 'inactive' as behavior_type,
+ COUNT(*) as user_count
+ FROM users
+ WHERE travel_count = 0 AND animal_claim_count = 0
+ `;
+ const behaviorStats = await query(behaviorSql);
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '获取成功',
+ data: {
+ activityStats,
+ levelDistribution,
+ behaviorStats
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 获取收入统计
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.getRevenueStatistics = async (req, res, next) => {
+ try {
+ const { period = '30d' } = req.query;
+
+ let days;
+ switch (period) {
+ case '7d':
+ days = 7;
+ break;
+ case '90d':
+ days = 90;
+ break;
+ case '365d':
+ days = 365;
+ break;
+ default:
+ days = 30;
+ }
+
+ // 收入趋势
+ const revenueTrendSql = `
+ SELECT
+ DATE(created_at) as date,
+ COALESCE(SUM(CASE WHEN status = 'completed' THEN amount END), 0) as daily_revenue,
+ COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_orders,
+ COUNT(*) as total_orders
+ FROM orders
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
+ GROUP BY DATE(created_at)
+ ORDER BY date ASC
+ `;
+ const revenueTrend = await query(revenueTrendSql);
+
+ // 收入来源分析
+ const revenueSourceSql = `
+ SELECT
+ order_type,
+ COUNT(*) as order_count,
+ COALESCE(SUM(CASE WHEN status = 'completed' THEN amount END), 0) as total_revenue,
+ AVG(CASE WHEN status = 'completed' THEN amount END) as avg_order_value
+ FROM orders
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
+ GROUP BY order_type
+ `;
+ const revenueSource = await query(revenueSourceSql);
+
+ // 支付方式统计
+ const paymentMethodSql = `
+ SELECT
+ payment_method,
+ COUNT(*) as order_count,
+ COALESCE(SUM(amount), 0) as total_amount
+ FROM orders
+ WHERE status = 'completed'
+ AND created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
+ GROUP BY payment_method
+ `;
+ const paymentMethodStats = await query(paymentMethodSql);
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '获取成功',
+ data: {
+ period,
+ revenueTrend,
+ revenueSource,
+ paymentMethodStats
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 导出统计报告
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.exportStatisticsReport = async (req, res, next) => {
+ try {
+ const {
+ reportType = 'overview',
+ period = '30d',
+ format = 'csv'
+ } = req.query;
+
+ let reportData = {};
+ const timestamp = new Date().toISOString().slice(0, 10);
+
+ switch (reportType) {
+ case 'overview':
+ // 获取系统概览数据
+ const overviewSql = `
+ SELECT
+ '用户总数' as metric, COUNT(*) as value FROM users
+ UNION ALL
+ SELECT
+ '活跃用户' as metric, COUNT(*) as value FROM users WHERE status = 'active'
+ UNION ALL
+ SELECT
+ '旅行总数' as metric, COUNT(*) as value FROM travels
+ UNION ALL
+ SELECT
+ '认领总数' as metric, COUNT(*) as value FROM animal_claims
+ UNION ALL
+ SELECT
+ '订单总数' as metric, COUNT(*) as value FROM orders
+ UNION ALL
+ SELECT
+ '总收入' as metric, COALESCE(SUM(amount), 0) as value FROM orders WHERE status = 'completed'
+ `;
+ reportData.overview = await query(overviewSql);
+ break;
+
+ case 'users':
+ // 用户详细报告
+ const userReportSql = `
+ SELECT
+ DATE(created_at) as date,
+ COUNT(*) as new_users,
+ COUNT(CASE WHEN user_type = 'farmer' THEN 1 END) as new_farmers,
+ COUNT(CASE WHEN user_type = 'merchant' THEN 1 END) as new_merchants
+ FROM users
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ GROUP BY DATE(created_at)
+ ORDER BY date ASC
+ `;
+ reportData.users = await query(userReportSql);
+ break;
+
+ case 'revenue':
+ // 收入报告
+ const revenueReportSql = `
+ SELECT
+ DATE(created_at) as date,
+ COUNT(*) as total_orders,
+ COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_orders,
+ COALESCE(SUM(CASE WHEN status = 'completed' THEN amount END), 0) as daily_revenue
+ FROM orders
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ GROUP BY DATE(created_at)
+ ORDER BY date ASC
+ `;
+ reportData.revenue = await query(revenueReportSql);
+ break;
+ }
+
+ if (format === 'csv') {
+ // 生成CSV格式
+ let csvContent = '';
+
+ Object.keys(reportData).forEach(key => {
+ csvContent += `\n${key.toUpperCase()} 报告\n`;
+ if (reportData[key].length > 0) {
+ // 添加表头
+ const headers = Object.keys(reportData[key][0]).join(',');
+ csvContent += headers + '\n';
+
+ // 添加数据
+ reportData[key].forEach(row => {
+ const values = Object.values(row).join(',');
+ csvContent += values + '\n';
+ });
+ }
+ });
+
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename=statistics_report_${timestamp}.csv`);
+ res.send('\uFEFF' + csvContent);
+ } else {
+ // 返回JSON格式
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '导出成功',
+ data: {
+ reportType,
+ period,
+ timestamp,
+ ...reportData
+ }
+ });
+ }
+
+ // 记录操作日志
+ const logSql = `
+ INSERT INTO admin_operation_logs (admin_id, operation_type, target_type, target_id, operation_detail, created_at)
+ VALUES (?, 'export_statistics', 'system', ?, ?, NOW())
+ `;
+ const operationDetail = JSON.stringify({
+ reportType,
+ period,
+ format
+ });
+ await query(logSql, [req.admin.id, 0, operationDetail]);
+
+ } catch (error) {
+ next(error);
+ }
+};
\ No newline at end of file
diff --git a/backend/src/controllers/admin/fileManagement.js b/backend/src/controllers/admin/fileManagement.js
new file mode 100644
index 0000000..f4ce061
--- /dev/null
+++ b/backend/src/controllers/admin/fileManagement.js
@@ -0,0 +1,590 @@
+/**
+ * 管理员文件管理控制器
+ * 处理文件上传、管理、统计等功能
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { AppError, ErrorTypes, catchAsync } = require('../../middleware/errorHandler');
+const { logBusinessOperation, logError } = require('../../utils/logger');
+const { deleteFile, getFileInfo } = require('../../middleware/upload');
+
+/**
+ * 获取文件列表
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+const getFileList = catchAsync(async (req, res) => {
+ const {
+ page = 1,
+ limit = 20,
+ type = 'all',
+ keyword = '',
+ start_date,
+ end_date,
+ sort_by = 'created_at',
+ sort_order = 'desc'
+ } = req.query;
+
+ const offset = (page - 1) * limit;
+ const uploadDir = path.join(__dirname, '../../../uploads');
+
+ try {
+ // 获取所有文件类型目录
+ const typeDirs = {
+ avatar: path.join(uploadDir, 'avatars'),
+ animal: path.join(uploadDir, 'animals'),
+ travel: path.join(uploadDir, 'travels'),
+ document: path.join(uploadDir, 'documents')
+ };
+
+ let allFiles = [];
+
+ // 根据类型筛选目录
+ const dirsToScan = type === 'all' ? Object.values(typeDirs) : [typeDirs[type]];
+
+ for (const dir of dirsToScan) {
+ if (!fs.existsSync(dir)) continue;
+
+ const files = fs.readdirSync(dir);
+ const fileType = Object.keys(typeDirs).find(key => typeDirs[key] === dir);
+
+ for (const file of files) {
+ const filePath = path.join(dir, file);
+ const stats = fs.statSync(filePath);
+
+ // 跳过缩略图文件
+ if (file.includes('_thumb')) continue;
+
+ // 关键词筛选
+ if (keyword && !file.toLowerCase().includes(keyword.toLowerCase())) {
+ continue;
+ }
+
+ // 日期筛选
+ if (start_date && stats.birthtime < new Date(start_date)) continue;
+ if (end_date && stats.birthtime > new Date(end_date)) continue;
+
+ const ext = path.extname(file).toLowerCase();
+ const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
+
+ allFiles.push({
+ id: Buffer.from(filePath).toString('base64'),
+ filename: file,
+ originalName: file,
+ type: fileType,
+ size: stats.size,
+ mimetype: isImage ? `image/${ext.slice(1)}` : 'application/octet-stream',
+ isImage,
+ url: `/uploads/${fileType}s/${file}`,
+ thumbnailUrl: isImage ? `/uploads/${fileType}s/${file.replace(ext, '_thumb' + ext)}` : null,
+ created_at: stats.birthtime,
+ modified_at: stats.mtime
+ });
+ }
+ }
+
+ // 排序
+ allFiles.sort((a, b) => {
+ const aValue = a[sort_by] || a.created_at;
+ const bValue = b[sort_by] || b.created_at;
+
+ if (sort_order === 'desc') {
+ return new Date(bValue) - new Date(aValue);
+ } else {
+ return new Date(aValue) - new Date(bValue);
+ }
+ });
+
+ // 分页
+ const total = allFiles.length;
+ const files = allFiles.slice(offset, offset + parseInt(limit));
+
+ // 记录操作日志
+ logBusinessOperation('file_list_viewed', 'file', {
+ page,
+ limit,
+ type,
+ keyword,
+ total
+ }, req.user);
+
+ res.json({
+ success: true,
+ message: '获取成功',
+ data: {
+ files,
+ pagination: {
+ current_page: parseInt(page),
+ per_page: parseInt(limit),
+ total,
+ total_pages: Math.ceil(total / limit)
+ }
+ }
+ });
+ } catch (error) {
+ logError(error, {
+ type: 'file_list_error',
+ userId: req.user?.id,
+ query: req.query
+ });
+ throw ErrorTypes.INTERNAL_ERROR('获取文件列表失败');
+ }
+});
+
+/**
+ * 获取文件详情
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+const getFileDetail = catchAsync(async (req, res) => {
+ const { file_id } = req.params;
+
+ try {
+ // 解码文件路径
+ const filePath = Buffer.from(file_id, 'base64').toString();
+
+ if (!fs.existsSync(filePath)) {
+ throw ErrorTypes.NOT_FOUND('文件不存在');
+ }
+
+ const stats = fs.statSync(filePath);
+ const filename = path.basename(filePath);
+ const ext = path.extname(filename).toLowerCase();
+ const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
+
+ // 获取文件类型
+ const uploadDir = path.join(__dirname, '../../../uploads');
+ const relativePath = path.relative(uploadDir, filePath);
+ const fileType = relativePath.split(path.sep)[0].replace('s', ''); // avatars -> avatar
+
+ const fileDetail = {
+ id: file_id,
+ filename,
+ originalName: filename,
+ type: fileType,
+ size: stats.size,
+ mimetype: isImage ? `image/${ext.slice(1)}` : 'application/octet-stream',
+ isImage,
+ url: `/uploads/${fileType}s/${filename}`,
+ thumbnailUrl: isImage ? `/uploads/${fileType}s/${filename.replace(ext, '_thumb' + ext)}` : null,
+ created_at: stats.birthtime,
+ modified_at: stats.mtime,
+ path: relativePath
+ };
+
+ // 记录操作日志
+ logBusinessOperation('file_detail_viewed', 'file', {
+ fileId: file_id,
+ filename
+ }, req.user);
+
+ res.json({
+ success: true,
+ message: '获取成功',
+ data: {
+ file: fileDetail
+ }
+ });
+ } catch (error) {
+ if (error instanceof AppError) {
+ throw error;
+ }
+
+ logError(error, {
+ type: 'file_detail_error',
+ userId: req.user?.id,
+ fileId: file_id
+ });
+ throw ErrorTypes.INTERNAL_ERROR('获取文件详情失败');
+ }
+});
+
+/**
+ * 删除文件
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+const deleteFileById = catchAsync(async (req, res) => {
+ const { file_id } = req.params;
+
+ try {
+ // 解码文件路径
+ const filePath = Buffer.from(file_id, 'base64').toString();
+ const filename = path.basename(filePath);
+
+ if (!fs.existsSync(filePath)) {
+ throw ErrorTypes.NOT_FOUND('文件不存在');
+ }
+
+ // 删除文件
+ const deleted = await deleteFile(filePath);
+
+ if (!deleted) {
+ throw ErrorTypes.INTERNAL_ERROR('文件删除失败');
+ }
+
+ // 记录操作日志
+ logBusinessOperation('file_deleted', 'file', {
+ fileId: file_id,
+ filename,
+ filePath
+ }, req.user);
+
+ res.json({
+ success: true,
+ message: '文件删除成功'
+ });
+ } catch (error) {
+ if (error instanceof AppError) {
+ throw error;
+ }
+
+ logError(error, {
+ type: 'file_deletion_error',
+ userId: req.user?.id,
+ fileId: file_id
+ });
+ throw ErrorTypes.INTERNAL_ERROR('删除文件失败');
+ }
+});
+
+/**
+ * 批量删除文件
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+const batchDeleteFiles = catchAsync(async (req, res) => {
+ const { file_ids } = req.body;
+
+ if (!Array.isArray(file_ids) || file_ids.length === 0) {
+ throw ErrorTypes.VALIDATION_ERROR('请提供要删除的文件ID列表');
+ }
+
+ if (file_ids.length > 50) {
+ throw ErrorTypes.VALIDATION_ERROR('单次最多删除50个文件');
+ }
+
+ const results = {
+ success: [],
+ failed: []
+ };
+
+ for (const file_id of file_ids) {
+ try {
+ // 解码文件路径
+ const filePath = Buffer.from(file_id, 'base64').toString();
+ const filename = path.basename(filePath);
+
+ if (fs.existsSync(filePath)) {
+ const deleted = await deleteFile(filePath);
+
+ if (deleted) {
+ results.success.push({
+ file_id,
+ filename,
+ message: '删除成功'
+ });
+ } else {
+ results.failed.push({
+ file_id,
+ filename,
+ message: '删除失败'
+ });
+ }
+ } else {
+ results.failed.push({
+ file_id,
+ filename: '未知',
+ message: '文件不存在'
+ });
+ }
+ } catch (error) {
+ results.failed.push({
+ file_id,
+ filename: '未知',
+ message: error.message || '删除失败'
+ });
+ }
+ }
+
+ // 记录操作日志
+ logBusinessOperation('files_batch_deleted', 'file', {
+ totalFiles: file_ids.length,
+ successCount: results.success.length,
+ failedCount: results.failed.length
+ }, req.user);
+
+ res.json({
+ success: true,
+ message: `批量删除完成,成功: ${results.success.length},失败: ${results.failed.length}`,
+ data: results
+ });
+});
+
+/**
+ * 获取文件统计信息
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+const getFileStatistics = catchAsync(async (req, res) => {
+ const uploadDir = path.join(__dirname, '../../../uploads');
+
+ try {
+ const typeDirs = {
+ avatar: path.join(uploadDir, 'avatars'),
+ animal: path.join(uploadDir, 'animals'),
+ travel: path.join(uploadDir, 'travels'),
+ document: path.join(uploadDir, 'documents')
+ };
+
+ const stats = {
+ totalFiles: 0,
+ totalSize: 0,
+ typeStats: [],
+ sizeDistribution: {
+ small: 0, // < 1MB
+ medium: 0, // 1MB - 5MB
+ large: 0 // > 5MB
+ },
+ formatStats: {}
+ };
+
+ for (const [type, dir] of Object.entries(typeDirs)) {
+ if (!fs.existsSync(dir)) {
+ stats.typeStats.push({
+ type,
+ count: 0,
+ size: 0,
+ avgSize: 0
+ });
+ continue;
+ }
+
+ const files = fs.readdirSync(dir);
+ let typeCount = 0;
+ let typeSize = 0;
+
+ for (const file of files) {
+ // 跳过缩略图文件
+ if (file.includes('_thumb')) continue;
+
+ const filePath = path.join(dir, file);
+ const fileStat = fs.statSync(filePath);
+ const fileSize = fileStat.size;
+ const ext = path.extname(file).toLowerCase();
+
+ typeCount++;
+ typeSize += fileSize;
+ stats.totalFiles++;
+ stats.totalSize += fileSize;
+
+ // 大小分布统计
+ if (fileSize < 1024 * 1024) {
+ stats.sizeDistribution.small++;
+ } else if (fileSize < 5 * 1024 * 1024) {
+ stats.sizeDistribution.medium++;
+ } else {
+ stats.sizeDistribution.large++;
+ }
+
+ // 格式统计
+ if (!stats.formatStats[ext]) {
+ stats.formatStats[ext] = { count: 0, size: 0 };
+ }
+ stats.formatStats[ext].count++;
+ stats.formatStats[ext].size += fileSize;
+ }
+
+ stats.typeStats.push({
+ type,
+ count: typeCount,
+ size: typeSize,
+ avgSize: typeCount > 0 ? Math.round(typeSize / typeCount) : 0
+ });
+ }
+
+ // 转换格式统计为数组
+ const formatStatsArray = Object.entries(stats.formatStats).map(([format, data]) => ({
+ format,
+ count: data.count,
+ size: data.size,
+ percentage: ((data.count / stats.totalFiles) * 100).toFixed(2)
+ })).sort((a, b) => b.count - a.count);
+
+ stats.formatStats = formatStatsArray;
+
+ // 记录操作日志
+ logBusinessOperation('file_statistics_viewed', 'file', {
+ totalFiles: stats.totalFiles,
+ totalSize: stats.totalSize
+ }, req.user);
+
+ res.json({
+ success: true,
+ message: '获取成功',
+ data: stats
+ });
+ } catch (error) {
+ logError(error, {
+ type: 'file_statistics_error',
+ userId: req.user?.id
+ });
+ throw ErrorTypes.INTERNAL_ERROR('获取文件统计失败');
+ }
+});
+
+/**
+ * 清理无用文件
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+const cleanupUnusedFiles = catchAsync(async (req, res) => {
+ const { dry_run = true } = req.query;
+
+ try {
+ const uploadDir = path.join(__dirname, '../../../uploads');
+ const typeDirs = {
+ avatar: path.join(uploadDir, 'avatars'),
+ animal: path.join(uploadDir, 'animals'),
+ travel: path.join(uploadDir, 'travels'),
+ document: path.join(uploadDir, 'documents')
+ };
+
+ const results = {
+ scanned: 0,
+ unused: [],
+ deleted: [],
+ errors: []
+ };
+
+ // 这里应该根据实际业务逻辑检查文件是否被使用
+ // 目前只是示例,检查30天前的文件
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+
+ for (const [type, dir] of Object.entries(typeDirs)) {
+ if (!fs.existsSync(dir)) continue;
+
+ const files = fs.readdirSync(dir);
+
+ for (const file of files) {
+ // 跳过缩略图文件
+ if (file.includes('_thumb')) continue;
+
+ const filePath = path.join(dir, file);
+ const stats = fs.statSync(filePath);
+
+ results.scanned++;
+
+ // 检查文件是否超过30天且未被使用(这里需要根据实际业务逻辑实现)
+ if (stats.mtime < thirtyDaysAgo) {
+ results.unused.push({
+ filename: file,
+ type,
+ size: stats.size,
+ lastModified: stats.mtime
+ });
+
+ // 如果不是试运行,则删除文件
+ if (dry_run !== 'true') {
+ try {
+ const deleted = await deleteFile(filePath);
+ if (deleted) {
+ results.deleted.push({
+ filename: file,
+ type,
+ size: stats.size
+ });
+ }
+ } catch (error) {
+ results.errors.push({
+ filename: file,
+ type,
+ error: error.message
+ });
+ }
+ }
+ }
+ }
+ }
+
+ // 记录操作日志
+ logBusinessOperation('file_cleanup', 'file', {
+ dryRun: dry_run === 'true',
+ scanned: results.scanned,
+ unused: results.unused.length,
+ deleted: results.deleted.length,
+ errors: results.errors.length
+ }, req.user);
+
+ res.json({
+ success: true,
+ message: dry_run === 'true' ? '扫描完成(试运行)' : '清理完成',
+ data: results
+ });
+ } catch (error) {
+ logError(error, {
+ type: 'file_cleanup_error',
+ userId: req.user?.id,
+ dryRun: dry_run === 'true'
+ });
+ throw ErrorTypes.INTERNAL_ERROR('文件清理失败');
+ }
+});
+
+/**
+ * 上传文件
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+const uploadFile = catchAsync(async (req, res) => {
+ if (!req.file && !req.files) {
+ throw ErrorTypes.VALIDATION_ERROR('请选择要上传的文件');
+ }
+
+ const files = req.files || [req.file];
+ const uploadedFiles = [];
+
+ for (const file of files) {
+ const fileInfo = {
+ id: Buffer.from(file.path).toString('base64'),
+ filename: file.filename,
+ originalName: file.originalname,
+ size: file.size,
+ mimetype: file.mimetype,
+ url: file.path.replace(path.join(__dirname, '../../../'), '/'),
+ thumbnailUrl: file.thumbnail ? file.path.replace(path.basename(file.path), file.thumbnail).replace(path.join(__dirname, '../../../'), '/') : null,
+ created_at: new Date()
+ };
+
+ uploadedFiles.push(fileInfo);
+ }
+
+ // 记录操作日志
+ logBusinessOperation('files_uploaded', 'file', {
+ fileCount: uploadedFiles.length,
+ files: uploadedFiles.map(f => ({
+ filename: f.filename,
+ size: f.size,
+ mimetype: f.mimetype
+ }))
+ }, req.user);
+
+ res.json({
+ success: true,
+ message: '文件上传成功',
+ data: {
+ files: uploadedFiles
+ }
+ });
+});
+
+module.exports = {
+ getFileList,
+ getFileDetail,
+ deleteFileById,
+ batchDeleteFiles,
+ getFileStatistics,
+ cleanupUnusedFiles,
+ uploadFile
+};
\ No newline at end of file
diff --git a/backend/src/controllers/admin/userManagement.js b/backend/src/controllers/admin/userManagement.js
new file mode 100644
index 0000000..ea4ed81
--- /dev/null
+++ b/backend/src/controllers/admin/userManagement.js
@@ -0,0 +1,487 @@
+// 管理员用户管理控制器
+const User = require('../../models/user');
+const UserService = require('../../services/user');
+const { query } = require('../../config/database');
+
+/**
+ * 获取用户列表
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.getUserList = async (req, res, next) => {
+ try {
+ const {
+ page = 1,
+ pageSize = 10,
+ keyword = '',
+ userType = '',
+ status = '',
+ startDate = '',
+ endDate = '',
+ sortField = 'created_at',
+ sortOrder = 'desc'
+ } = req.query;
+
+ // 构建查询条件
+ let whereClause = 'WHERE 1=1';
+ const params = [];
+
+ // 关键词搜索
+ if (keyword) {
+ whereClause += ' AND (nickname LIKE ? OR phone LIKE ? OR email LIKE ?)';
+ const searchTerm = `%${keyword}%`;
+ params.push(searchTerm, searchTerm, searchTerm);
+ }
+
+ // 用户类型筛选
+ if (userType) {
+ whereClause += ' AND user_type = ?';
+ params.push(userType);
+ }
+
+ // 状态筛选
+ if (status) {
+ whereClause += ' AND status = ?';
+ params.push(status);
+ }
+
+ // 日期范围筛选
+ if (startDate) {
+ whereClause += ' AND created_at >= ?';
+ params.push(startDate);
+ }
+ if (endDate) {
+ whereClause += ' AND created_at <= ?';
+ params.push(endDate + ' 23:59:59');
+ }
+
+ // 计算总数
+ const countSql = `SELECT COUNT(*) as total FROM users ${whereClause}`;
+ const countResult = await query(countSql, params);
+ const total = countResult[0].total;
+
+ // 分页查询
+ const offset = (page - 1) * pageSize;
+ const orderBy = `ORDER BY ${sortField} ${sortOrder.toUpperCase()}`;
+ const listSql = `
+ SELECT
+ id, nickname, phone, email, user_type, status,
+ travel_count, animal_claim_count, points, level,
+ last_login_at, created_at, updated_at
+ FROM users
+ ${whereClause}
+ ${orderBy}
+ LIMIT ? OFFSET ?
+ `;
+
+ const listParams = [...params, parseInt(pageSize), offset];
+ const users = await query(listSql, listParams);
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '获取成功',
+ data: {
+ users,
+ pagination: {
+ page: parseInt(page),
+ pageSize: parseInt(pageSize),
+ total,
+ totalPages: Math.ceil(total / pageSize)
+ }
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 获取用户详情
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.getUserDetail = async (req, res, next) => {
+ try {
+ const { userId } = req.params;
+
+ // 获取用户基本信息
+ const userSql = `
+ SELECT
+ id, openid, nickname, avatar, gender, birthday, phone, email,
+ province, city, travel_count, animal_claim_count, points, level,
+ status, last_login_at, created_at, updated_at
+ FROM users
+ WHERE id = ?
+ `;
+ const userResult = await query(userSql, [userId]);
+
+ if (userResult.length === 0) {
+ return res.status(404).json({
+ success: false,
+ code: 404,
+ message: '用户不存在'
+ });
+ }
+
+ const user = userResult[0];
+
+ // 获取用户兴趣
+ const interestsSql = `
+ SELECT ui.interest_name, ui.created_at
+ FROM user_interests ui
+ WHERE ui.user_id = ?
+ `;
+ const interests = await query(interestsSql, [userId]);
+
+ // 获取用户最近的旅行记录
+ const travelsSql = `
+ SELECT id, title, destination, start_date, end_date, status, created_at
+ FROM travels
+ WHERE user_id = ?
+ ORDER BY created_at DESC
+ LIMIT 5
+ `;
+ const travels = await query(travelsSql, [userId]);
+
+ // 获取用户最近的认领记录
+ const claimsSql = `
+ SELECT ac.id, a.name as animal_name, ac.status, ac.created_at
+ FROM animal_claims ac
+ JOIN animals a ON ac.animal_id = a.id
+ WHERE ac.user_id = ?
+ ORDER BY ac.created_at DESC
+ LIMIT 5
+ `;
+ const claims = await query(claimsSql, [userId]);
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '获取成功',
+ data: {
+ user: {
+ ...user,
+ interests: interests.map(i => i.interest_name),
+ recentTravels: travels,
+ recentClaims: claims
+ }
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 更新用户状态
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.updateUserStatus = async (req, res, next) => {
+ try {
+ const { userId } = req.params;
+ const { status, reason } = req.body;
+
+ // 验证状态值
+ const validStatuses = ['active', 'inactive', 'banned'];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({
+ success: false,
+ code: 400,
+ message: '无效的状态值'
+ });
+ }
+
+ // 检查用户是否存在
+ const checkSql = 'SELECT id, status FROM users WHERE id = ?';
+ const checkResult = await query(checkSql, [userId]);
+
+ if (checkResult.length === 0) {
+ return res.status(404).json({
+ success: false,
+ code: 404,
+ message: '用户不存在'
+ });
+ }
+
+ // 更新用户状态
+ const updateSql = 'UPDATE users SET status = ?, updated_at = NOW() WHERE id = ?';
+ await query(updateSql, [status, userId]);
+
+ // 记录操作日志
+ const logSql = `
+ INSERT INTO admin_operation_logs (admin_id, operation_type, target_type, target_id, operation_detail, created_at)
+ VALUES (?, 'update_user_status', 'user', ?, ?, NOW())
+ `;
+ const operationDetail = JSON.stringify({
+ old_status: checkResult[0].status,
+ new_status: status,
+ reason: reason || '无'
+ });
+ await query(logSql, [req.admin.id, userId, operationDetail]);
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '状态更新成功'
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 批量更新用户状态
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.batchUpdateUserStatus = async (req, res, next) => {
+ try {
+ const { userIds, status, reason } = req.body;
+
+ // 验证输入
+ if (!Array.isArray(userIds) || userIds.length === 0) {
+ return res.status(400).json({
+ success: false,
+ code: 400,
+ message: '用户ID列表不能为空'
+ });
+ }
+
+ const validStatuses = ['active', 'inactive', 'banned'];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({
+ success: false,
+ code: 400,
+ message: '无效的状态值'
+ });
+ }
+
+ // 批量更新
+ const placeholders = userIds.map(() => '?').join(',');
+ const updateSql = `UPDATE users SET status = ?, updated_at = NOW() WHERE id IN (${placeholders})`;
+ const updateParams = [status, ...userIds];
+
+ const result = await query(updateSql, updateParams);
+
+ // 记录操作日志
+ const logSql = `
+ INSERT INTO admin_operation_logs (admin_id, operation_type, target_type, target_id, operation_detail, created_at)
+ VALUES (?, 'batch_update_user_status', 'user', ?, ?, NOW())
+ `;
+ const operationDetail = JSON.stringify({
+ user_ids: userIds,
+ new_status: status,
+ reason: reason || '无',
+ affected_rows: result.affectedRows
+ });
+ await query(logSql, [req.admin.id, 0, operationDetail]);
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: `成功更新 ${result.affectedRows} 个用户的状态`
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 获取用户统计信息
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.getUserStatistics = async (req, res, next) => {
+ try {
+ const { period = '7d' } = req.query;
+
+ // 基础统计
+ const basicStatsSql = `
+ SELECT
+ COUNT(*) as total_users,
+ COUNT(CASE WHEN status = 'active' THEN 1 END) as active_users,
+ COUNT(CASE WHEN status = 'inactive' THEN 1 END) as inactive_users,
+ COUNT(CASE WHEN status = 'banned' THEN 1 END) as banned_users,
+ COUNT(CASE WHEN user_type = 'farmer' THEN 1 END) as farmers,
+ COUNT(CASE WHEN user_type = 'merchant' THEN 1 END) as merchants,
+ COUNT(CASE WHEN DATE(created_at) = CURDATE() THEN 1 END) as new_users_today,
+ COUNT(CASE WHEN DATE(created_at) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN 1 END) as new_users_week,
+ COUNT(CASE WHEN DATE(created_at) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN 1 END) as new_users_month
+ FROM users
+ `;
+ const basicStats = await query(basicStatsSql);
+
+ // 用户等级分布
+ const levelStatsSql = `
+ SELECT
+ level,
+ COUNT(*) as count
+ FROM users
+ GROUP BY level
+ `;
+ const levelStats = await query(levelStatsSql);
+
+ // 根据时间周期获取趋势数据
+ let trendSql;
+ let trendDays;
+
+ switch (period) {
+ case '30d':
+ trendDays = 30;
+ break;
+ case '90d':
+ trendDays = 90;
+ break;
+ default:
+ trendDays = 7;
+ }
+
+ trendSql = `
+ SELECT
+ DATE(created_at) as date,
+ COUNT(*) as new_users,
+ COUNT(CASE WHEN user_type = 'farmer' THEN 1 END) as new_farmers,
+ COUNT(CASE WHEN user_type = 'merchant' THEN 1 END) as new_merchants
+ FROM users
+ WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${trendDays} DAY)
+ GROUP BY DATE(created_at)
+ ORDER BY date ASC
+ `;
+ const trendData = await query(trendSql);
+
+ // 活跃用户统计(最近30天有登录的用户)
+ const activeUsersSql = `
+ SELECT COUNT(*) as active_users_30d
+ FROM users
+ WHERE last_login_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
+ `;
+ const activeUsersResult = await query(activeUsersSql);
+
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '获取成功',
+ data: {
+ basicStats: basicStats[0],
+ levelDistribution: levelStats,
+ trendData,
+ activeUsers30d: activeUsersResult[0].active_users_30d
+ }
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * 导出用户数据
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+exports.exportUsers = async (req, res, next) => {
+ try {
+ const {
+ format = 'csv',
+ userType = '',
+ status = '',
+ startDate = '',
+ endDate = ''
+ } = req.query;
+
+ // 构建查询条件
+ let whereClause = 'WHERE 1=1';
+ const params = [];
+
+ if (userType) {
+ whereClause += ' AND user_type = ?';
+ params.push(userType);
+ }
+
+ if (status) {
+ whereClause += ' AND status = ?';
+ params.push(status);
+ }
+
+ if (startDate) {
+ whereClause += ' AND created_at >= ?';
+ params.push(startDate);
+ }
+
+ if (endDate) {
+ whereClause += ' AND created_at <= ?';
+ params.push(endDate + ' 23:59:59');
+ }
+
+ // 查询用户数据
+ const exportSql = `
+ SELECT
+ id, nickname, phone, email, user_type, status,
+ travel_count, animal_claim_count, points, level,
+ created_at, last_login_at
+ FROM users
+ ${whereClause}
+ ORDER BY created_at DESC
+ `;
+
+ const users = await query(exportSql, params);
+
+ if (format === 'csv') {
+ // 生成CSV格式
+ const csvHeader = 'ID,昵称,手机号,邮箱,用户类型,状态,旅行次数,认领次数,积分,等级,注册时间,最后登录\n';
+ const csvData = users.map(user => {
+ return [
+ user.id,
+ user.nickname || '',
+ user.phone || '',
+ user.email || '',
+ user.user_type || '',
+ user.status,
+ user.travel_count,
+ user.animal_claim_count,
+ user.points,
+ user.level,
+ user.created_at,
+ user.last_login_at || ''
+ ].join(',');
+ }).join('\n');
+
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename=users_${Date.now()}.csv`);
+ res.send('\uFEFF' + csvHeader + csvData); // 添加BOM以支持中文
+ } else {
+ // 返回JSON格式
+ res.status(200).json({
+ success: true,
+ code: 200,
+ message: '导出成功',
+ data: {
+ users,
+ total: users.length
+ }
+ });
+ }
+
+ // 记录操作日志
+ const logSql = `
+ INSERT INTO admin_operation_logs (admin_id, operation_type, target_type, target_id, operation_detail, created_at)
+ VALUES (?, 'export_users', 'user', ?, ?, NOW())
+ `;
+ const operationDetail = JSON.stringify({
+ format,
+ filters: { userType, status, startDate, endDate },
+ exported_count: users.length
+ });
+ await query(logSql, [req.admin.id, 0, operationDetail]);
+
+ } catch (error) {
+ next(error);
+ }
+};
\ No newline at end of file
diff --git a/backend/src/controllers/animalClaim.js b/backend/src/controllers/animalClaim.js
new file mode 100644
index 0000000..73456fd
--- /dev/null
+++ b/backend/src/controllers/animalClaim.js
@@ -0,0 +1,438 @@
+const AnimalClaimService = require('../services/animalClaim');
+const { validateRequired, validatePositiveInteger } = require('../utils/validation');
+
+class AnimalClaimController {
+ /**
+ * 申请认领动物
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async createClaim(req, res) {
+ try {
+ const { animal_id, claim_reason, claim_duration, contact_info } = req.body;
+ const user_id = req.user.id;
+
+ // 参数验证
+ if (!validateRequired(animal_id) || !validatePositiveInteger(animal_id)) {
+ return res.status(400).json({
+ success: false,
+ message: '动物ID不能为空且必须为正整数'
+ });
+ }
+
+ if (!validateRequired(contact_info)) {
+ return res.status(400).json({
+ success: false,
+ message: '联系方式不能为空'
+ });
+ }
+
+ if (claim_duration && (!validatePositiveInteger(claim_duration) || claim_duration < 1 || claim_duration > 60)) {
+ return res.status(400).json({
+ success: false,
+ message: '认领时长必须为1-60个月之间的整数'
+ });
+ }
+
+ // 创建认领申请
+ const claim = await AnimalClaimService.createClaim({
+ animal_id: parseInt(animal_id),
+ user_id,
+ claim_reason,
+ claim_duration: claim_duration ? parseInt(claim_duration) : 12,
+ contact_info
+ });
+
+ res.status(201).json({
+ success: true,
+ message: '认领申请提交成功',
+ data: claim
+ });
+ } catch (error) {
+ console.error('创建认领申请控制器错误:', error);
+ res.status(400).json({
+ success: false,
+ message: error.message || '创建认领申请失败'
+ });
+ }
+ }
+
+ /**
+ * 取消认领申请
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async cancelClaim(req, res) {
+ try {
+ const { id } = req.params;
+ const user_id = req.user.id;
+
+ // 参数验证
+ if (!validatePositiveInteger(id)) {
+ return res.status(400).json({
+ success: false,
+ message: '认领申请ID无效'
+ });
+ }
+
+ // 取消认领申请
+ const claim = await AnimalClaimService.cancelClaim(parseInt(id), user_id);
+
+ res.json({
+ success: true,
+ message: '认领申请已取消',
+ data: claim
+ });
+ } catch (error) {
+ console.error('取消认领申请控制器错误:', error);
+ res.status(400).json({
+ success: false,
+ message: error.message || '取消认领申请失败'
+ });
+ }
+ }
+
+ /**
+ * 获取用户的认领申请列表
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async getUserClaims(req, res) {
+ try {
+ const user_id = req.user.id;
+ const {
+ page = 1,
+ limit = 10,
+ status,
+ animal_type,
+ start_date,
+ end_date
+ } = req.query;
+
+ // 参数验证
+ if (!validatePositiveInteger(page) || !validatePositiveInteger(limit)) {
+ return res.status(400).json({
+ success: false,
+ message: '分页参数必须为正整数'
+ });
+ }
+
+ if (parseInt(limit) > 100) {
+ return res.status(400).json({
+ success: false,
+ message: '每页数量不能超过100'
+ });
+ }
+
+ // 获取认领申请列表
+ const result = await AnimalClaimService.getUserClaims(user_id, {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ status,
+ animal_type,
+ start_date,
+ end_date
+ });
+
+ res.json({
+ success: true,
+ message: '获取认领申请列表成功',
+ data: result.data,
+ pagination: result.pagination
+ });
+ } catch (error) {
+ console.error('获取用户认领申请列表控制器错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取认领申请列表失败'
+ });
+ }
+ }
+
+ /**
+ * 获取动物的认领申请列表
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async getAnimalClaims(req, res) {
+ try {
+ const { animal_id } = req.params;
+ const {
+ page = 1,
+ limit = 10,
+ status
+ } = req.query;
+
+ // 参数验证
+ if (!validatePositiveInteger(animal_id)) {
+ return res.status(400).json({
+ success: false,
+ message: '动物ID无效'
+ });
+ }
+
+ if (!validatePositiveInteger(page) || !validatePositiveInteger(limit)) {
+ return res.status(400).json({
+ success: false,
+ message: '分页参数必须为正整数'
+ });
+ }
+
+ // 获取动物认领申请列表
+ const result = await AnimalClaimService.getAnimalClaims(parseInt(animal_id), {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ status
+ });
+
+ res.json({
+ success: true,
+ message: '获取动物认领申请列表成功',
+ data: result.data,
+ pagination: result.pagination
+ });
+ } catch (error) {
+ console.error('获取动物认领申请列表控制器错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取动物认领申请列表失败'
+ });
+ }
+ }
+
+ /**
+ * 获取所有认领申请列表(管理员)
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async getAllClaims(req, res) {
+ try {
+ const {
+ page = 1,
+ limit = 10,
+ status,
+ animal_type,
+ user_id,
+ start_date,
+ end_date,
+ keyword
+ } = req.query;
+
+ // 参数验证
+ if (!validatePositiveInteger(page) || !validatePositiveInteger(limit)) {
+ return res.status(400).json({
+ success: false,
+ message: '分页参数必须为正整数'
+ });
+ }
+
+ if (parseInt(limit) > 100) {
+ return res.status(400).json({
+ success: false,
+ message: '每页数量不能超过100'
+ });
+ }
+
+ // 获取所有认领申请列表
+ const result = await AnimalClaimService.getAllClaims({
+ page: parseInt(page),
+ limit: parseInt(limit),
+ status,
+ animal_type,
+ user_id: user_id ? parseInt(user_id) : undefined,
+ start_date,
+ end_date,
+ keyword
+ });
+
+ res.json({
+ success: true,
+ message: '获取认领申请列表成功',
+ data: result.data,
+ pagination: result.pagination
+ });
+ } catch (error) {
+ console.error('获取所有认领申请列表控制器错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取认领申请列表失败'
+ });
+ }
+ }
+
+ /**
+ * 审核认领申请
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async reviewClaim(req, res) {
+ try {
+ const { id } = req.params;
+ const { status, review_remark } = req.body;
+ const reviewed_by = req.user.id;
+
+ // 参数验证
+ if (!validatePositiveInteger(id)) {
+ return res.status(400).json({
+ success: false,
+ message: '认领申请ID无效'
+ });
+ }
+
+ if (!validateRequired(status)) {
+ return res.status(400).json({
+ success: false,
+ message: '审核状态不能为空'
+ });
+ }
+
+ const validStatuses = ['approved', 'rejected'];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({
+ success: false,
+ message: '无效的审核状态'
+ });
+ }
+
+ // 审核认领申请
+ const claim = await AnimalClaimService.reviewClaim(parseInt(id), status, {
+ reviewed_by,
+ review_remark
+ });
+
+ res.json({
+ success: true,
+ message: `认领申请${status === 'approved' ? '审核通过' : '审核拒绝'}`,
+ data: claim
+ });
+ } catch (error) {
+ console.error('审核认领申请控制器错误:', error);
+ res.status(400).json({
+ success: false,
+ message: error.message || '审核认领申请失败'
+ });
+ }
+ }
+
+ /**
+ * 续期认领
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async renewClaim(req, res) {
+ try {
+ const { id } = req.params;
+ const { duration, payment_method } = req.body;
+ const user_id = req.user.id;
+
+ // 参数验证
+ if (!validatePositiveInteger(id)) {
+ return res.status(400).json({
+ success: false,
+ message: '认领申请ID无效'
+ });
+ }
+
+ if (!validateRequired(duration) || !validatePositiveInteger(duration) || duration < 1 || duration > 60) {
+ return res.status(400).json({
+ success: false,
+ message: '续期时长必须为1-60个月之间的整数'
+ });
+ }
+
+ if (!validateRequired(payment_method)) {
+ return res.status(400).json({
+ success: false,
+ message: '支付方式不能为空'
+ });
+ }
+
+ // 续期认领
+ const result = await AnimalClaimService.renewClaim(parseInt(id), user_id, {
+ duration: parseInt(duration),
+ payment_method
+ });
+
+ res.json({
+ success: true,
+ message: result.message,
+ data: result
+ });
+ } catch (error) {
+ console.error('续期认领控制器错误:', error);
+ res.status(400).json({
+ success: false,
+ message: error.message || '续期认领失败'
+ });
+ }
+ }
+
+ /**
+ * 获取认领统计信息
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async getClaimStatistics(req, res) {
+ try {
+ const { start_date, end_date, animal_type } = req.query;
+
+ // 获取认领统计信息
+ const statistics = await AnimalClaimService.getClaimStatistics({
+ start_date,
+ end_date,
+ animal_type
+ });
+
+ res.json({
+ success: true,
+ message: '获取认领统计信息成功',
+ data: statistics
+ });
+ } catch (error) {
+ console.error('获取认领统计信息控制器错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '获取认领统计信息失败'
+ });
+ }
+ }
+
+ /**
+ * 检查认领权限
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async checkClaimPermission(req, res) {
+ try {
+ const { animal_id } = req.params;
+ const user_id = req.user.id;
+
+ // 参数验证
+ if (!validatePositiveInteger(animal_id)) {
+ return res.status(400).json({
+ success: false,
+ message: '动物ID无效'
+ });
+ }
+
+ // 检查认领权限
+ const hasPermission = await AnimalClaimService.checkClaimPermission(user_id, parseInt(animal_id));
+
+ res.json({
+ success: true,
+ message: '检查认领权限成功',
+ data: {
+ can_claim: hasPermission
+ }
+ });
+ } catch (error) {
+ console.error('检查认领权限控制器错误:', error);
+ res.status(500).json({
+ success: false,
+ message: '检查认领权限失败'
+ });
+ }
+ }
+}
+
+module.exports = new AnimalClaimController();
\ No newline at end of file
diff --git a/backend/src/controllers/authControllerMySQL.js b/backend/src/controllers/authControllerMySQL.js
index 437eee4..a358938 100644
--- a/backend/src/controllers/authControllerMySQL.js
+++ b/backend/src/controllers/authControllerMySQL.js
@@ -1,8 +1,10 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
+const crypto = require('crypto');
const UserMySQL = require('../models/UserMySQL');
const { AppError } = require('../utils/errors');
const { success } = require('../utils/response');
+const { sendEmail } = require('../utils/email');
// 生成JWT Token
const generateToken = (userId) => {
@@ -13,6 +15,20 @@ const generateToken = (userId) => {
);
};
+// 生成刷新Token
+const generateRefreshToken = (userId) => {
+ return jwt.sign(
+ { userId, type: 'refresh' },
+ process.env.JWT_REFRESH_SECRET || 'your-refresh-secret-key',
+ { expiresIn: process.env.JWT_REFRESH_EXPIRE || '30d' }
+ );
+};
+
+// 生成验证码
+const generateVerificationCode = () => {
+ return crypto.randomBytes(32).toString('hex');
+};
+
// 用户注册
const register = async (req, res, next) => {
try {
@@ -50,8 +66,9 @@ const register = async (req, res, next) => {
// 获取用户信息
const user = await UserMySQL.findById(userId);
- // 生成token
+ // 生成token和刷新token
const token = generateToken(userId);
+ const refreshToken = generateRefreshToken(userId);
// 更新最后登录时间
await UserMySQL.updateLastLogin(userId);
@@ -59,6 +76,7 @@ const register = async (req, res, next) => {
res.status(201).json(success({
user: UserMySQL.sanitize(user),
token,
+ refreshToken,
message: '注册成功'
}));
} catch (error) {
@@ -99,8 +117,9 @@ const login = async (req, res, next) => {
throw new AppError('密码错误', 401);
}
- // 生成token
+ // 生成token和刷新token
const token = generateToken(user.id);
+ const refreshToken = generateRefreshToken(user.id);
// 更新最后登录时间
await UserMySQL.updateLastLogin(user.id);
@@ -108,6 +127,7 @@ const login = async (req, res, next) => {
res.json(success({
user: UserMySQL.sanitize(user),
token,
+ refreshToken,
message: '登录成功'
}));
} catch (error) {
@@ -307,6 +327,178 @@ const adminLogin = async (req, res, next) => {
}
};
+// 刷新Token
+const refreshToken = async (req, res, next) => {
+ try {
+ const { refreshToken } = req.body;
+
+ if (!refreshToken) {
+ throw new AppError('刷新token不能为空', 400);
+ }
+
+ // 验证刷新token
+ const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || 'your-refresh-secret-key');
+
+ if (decoded.type !== 'refresh') {
+ throw new AppError('无效的刷新token', 401);
+ }
+
+ // 查找用户
+ const user = await UserMySQL.findById(decoded.userId);
+ if (!user) {
+ throw new AppError('用户不存在', 404);
+ }
+
+ // 检查用户状态
+ if (!UserMySQL.isActive(user)) {
+ throw new AppError('账户已被禁用', 403);
+ }
+
+ // 生成新的访问token
+ const newToken = generateToken(user.id);
+
+ res.json(success({
+ token: newToken,
+ message: 'Token刷新成功'
+ }));
+ } catch (error) {
+ if (error.name === 'JsonWebTokenError') {
+ throw new AppError('无效的刷新token', 401);
+ }
+
+ if (error.name === 'TokenExpiredError') {
+ throw new AppError('刷新token已过期', 401);
+ }
+
+ next(error);
+ }
+};
+
+// 发送邮箱验证码
+const sendEmailVerification = async (req, res, next) => {
+ try {
+ const { email } = req.body;
+
+ if (!email) {
+ throw new AppError('邮箱不能为空', 400);
+ }
+
+ // 检查邮箱格式
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ throw new AppError('邮箱格式不正确', 400);
+ }
+
+ // 生成验证码
+ const verificationCode = generateVerificationCode();
+ const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10分钟后过期
+
+ // 保存验证码到数据库(这里需要创建一个验证码表)
+ await UserMySQL.saveVerificationCode(email, verificationCode, expiresAt);
+
+ // 发送邮件
+ await sendEmail({
+ to: email,
+ subject: '结伴客 - 邮箱验证',
+ html: `
+
邮箱验证
+ 您的验证码是:${verificationCode}
+ 验证码将在10分钟后过期,请及时使用。
+ 如果这不是您的操作,请忽略此邮件。
+ `
+ });
+
+ res.json(success({
+ message: '验证码已发送到您的邮箱'
+ }));
+ } catch (error) {
+ next(error);
+ }
+};
+
+// 忘记密码
+const forgotPassword = async (req, res, next) => {
+ try {
+ const { email } = req.body;
+
+ if (!email) {
+ throw new AppError('邮箱不能为空', 400);
+ }
+
+ // 查找用户
+ const user = await UserMySQL.findByEmail(email);
+ if (!user) {
+ // 为了安全,不暴露用户是否存在
+ res.json(success({
+ message: '如果该邮箱已注册,重置密码链接已发送到您的邮箱'
+ }));
+ return;
+ }
+
+ // 生成重置token
+ const resetToken = generateVerificationCode();
+ const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 30分钟后过期
+
+ // 保存重置token
+ await UserMySQL.savePasswordResetToken(user.id, resetToken, expiresAt);
+
+ // 发送重置邮件
+ await sendEmail({
+ to: email,
+ subject: '结伴客 - 密码重置',
+ html: `
+ 密码重置
+ 您请求重置密码,请点击下面的链接重置您的密码:
+ 重置密码
+ 此链接将在30分钟后过期。
+ 如果这不是您的操作,请忽略此邮件。
+ `
+ });
+
+ res.json(success({
+ message: '如果该邮箱已注册,重置密码链接已发送到您的邮箱'
+ }));
+ } catch (error) {
+ next(error);
+ }
+};
+
+// 重置密码
+const resetPassword = async (req, res, next) => {
+ try {
+ const { token, newPassword } = req.body;
+
+ if (!token || !newPassword) {
+ throw new AppError('重置token和新密码不能为空', 400);
+ }
+
+ if (newPassword.length < 6) {
+ throw new AppError('密码长度不能少于6位', 400);
+ }
+
+ // 验证重置token
+ const resetData = await UserMySQL.findPasswordResetToken(token);
+ if (!resetData || new Date() > resetData.expires_at) {
+ throw new AppError('重置token无效或已过期', 400);
+ }
+
+ // 加密新密码
+ const hashedPassword = await bcrypt.hash(newPassword, 12);
+
+ // 更新密码
+ await UserMySQL.updatePassword(resetData.user_id, hashedPassword);
+
+ // 删除重置token
+ await UserMySQL.deletePasswordResetToken(token);
+
+ res.json(success({
+ message: '密码重置成功'
+ }));
+ } catch (error) {
+ next(error);
+ }
+};
+
module.exports = {
register,
login,
@@ -314,5 +506,9 @@ module.exports = {
updateProfile,
changePassword,
wechatLogin,
- adminLogin
+ adminLogin,
+ refreshToken,
+ sendEmailVerification,
+ forgotPassword,
+ resetPassword
};
\ No newline at end of file
diff --git a/backend/src/controllers/order/index.js b/backend/src/controllers/order/index.js
index 1ef08ed..3e2486a 100644
--- a/backend/src/controllers/order/index.js
+++ b/backend/src/controllers/order/index.js
@@ -191,44 +191,43 @@ async function cancelOrder(req, res, next) {
async function payOrder(req, res, next) {
try {
const { orderId } = req.params;
- const userId = req.user.id;
const paymentData = req.body;
+ const userId = req.user.id;
// 验证必要字段
- if (!paymentData.payment_method) {
+ if (!paymentData.payment_method || !paymentData.amount) {
return res.status(400).json({
success: false,
- message: '缺少必要字段: payment_method'
+ message: '缺少必要字段: payment_method, amount'
});
}
- const order = await OrderService.payOrder(orderId, userId, paymentData);
-
- res.json({
- success: true,
- message: '订单支付成功',
- data: order
- });
- } catch (error) {
- console.error('支付订单控制器错误:', error);
- if (error.message === '订单不存在') {
+ // 获取订单并验证权限
+ const order = await OrderService.getOrderById(orderId);
+ if (!order) {
return res.status(404).json({
success: false,
message: '订单不存在'
});
}
- if (error.message === '无权操作此订单') {
+
+ // 检查权限:用户只能支付自己的订单
+ if (req.user.role === 'user' && order.user_id !== userId) {
return res.status(403).json({
success: false,
message: '无权操作此订单'
});
}
- if (error.message === '订单状态不允许支付') {
- return res.status(400).json({
- success: false,
- message: '订单状态不允许支付'
- });
- }
+
+ const result = await OrderService.payOrder(orderId, paymentData);
+
+ res.json({
+ success: true,
+ message: '支付订单创建成功',
+ data: result
+ });
+ } catch (error) {
+ console.error('支付订单控制器错误:', error);
res.status(500).json({
success: false,
message: error.message || '支付订单失败'
diff --git a/backend/src/controllers/payment.js b/backend/src/controllers/payment.js
new file mode 100644
index 0000000..867a195
--- /dev/null
+++ b/backend/src/controllers/payment.js
@@ -0,0 +1,371 @@
+const PaymentService = require('../services/payment');
+const { validationResult } = require('express-validator');
+
+class PaymentController {
+ /**
+ * 创建支付订单
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async createPayment(req, res) {
+ try {
+ // 验证请求参数
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({
+ success: false,
+ message: '参数验证失败',
+ errors: errors.array()
+ });
+ }
+
+ const paymentData = req.body;
+ const userId = req.user.id;
+
+ // 验证必要字段
+ if (!paymentData.order_id || !paymentData.amount || !paymentData.payment_method) {
+ return res.status(400).json({
+ success: false,
+ message: '缺少必要字段: order_id, amount, payment_method'
+ });
+ }
+
+ // 添加用户ID
+ paymentData.user_id = userId;
+
+ const payment = await PaymentService.createPayment(paymentData);
+
+ res.status(201).json({
+ success: true,
+ message: '支付订单创建成功',
+ data: payment
+ });
+ } catch (error) {
+ console.error('创建支付订单控制器错误:', error);
+ res.status(500).json({
+ success: false,
+ message: error.message || '创建支付订单失败'
+ });
+ }
+ }
+
+ /**
+ * 获取支付订单详情
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async getPayment(req, res) {
+ try {
+ const { paymentId } = req.params;
+ const userId = req.user.id;
+
+ const payment = await PaymentService.getPaymentById(paymentId);
+
+ // 检查权限:用户只能查看自己的支付订单
+ if (req.user.role === 'user' && payment.user_id !== userId) {
+ return res.status(403).json({
+ success: false,
+ message: '无权访问此支付订单'
+ });
+ }
+
+ res.json({
+ success: true,
+ data: payment
+ });
+ } catch (error) {
+ console.error('获取支付订单控制器错误:', error);
+ if (error.message === '支付订单不存在') {
+ return res.status(404).json({
+ success: false,
+ message: '支付订单不存在'
+ });
+ }
+ res.status(500).json({
+ success: false,
+ message: error.message || '获取支付订单失败'
+ });
+ }
+ }
+
+ /**
+ * 查询支付状态
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async queryPaymentStatus(req, res) {
+ try {
+ const { paymentNo } = req.params;
+ const userId = req.user.id;
+
+ const payment = await PaymentService.getPaymentByNo(paymentNo);
+
+ // 检查权限
+ if (req.user.role === 'user' && payment.user_id !== userId) {
+ return res.status(403).json({
+ success: false,
+ message: '无权访问此支付订单'
+ });
+ }
+
+ res.json({
+ success: true,
+ data: {
+ payment_no: payment.payment_no,
+ status: payment.status,
+ amount: payment.amount,
+ paid_at: payment.paid_at,
+ transaction_id: payment.transaction_id
+ }
+ });
+ } catch (error) {
+ console.error('查询支付状态控制器错误:', error);
+ if (error.message === '支付订单不存在') {
+ return res.status(404).json({
+ success: false,
+ message: '支付订单不存在'
+ });
+ }
+ res.status(500).json({
+ success: false,
+ message: error.message || '查询支付状态失败'
+ });
+ }
+ }
+
+ /**
+ * 处理支付回调(微信支付)
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async handleWechatCallback(req, res) {
+ try {
+ const callbackData = req.body;
+
+ // 验证回调数据
+ if (!callbackData.out_trade_no || !callbackData.transaction_id) {
+ return res.status(400).json({
+ success: false,
+ message: '回调数据不完整'
+ });
+ }
+
+ // 处理支付回调
+ const payment = await PaymentService.handlePaymentCallback({
+ payment_no: callbackData.out_trade_no,
+ transaction_id: callbackData.transaction_id,
+ status: callbackData.result_code === 'SUCCESS' ? 'paid' : 'failed',
+ paid_amount: callbackData.total_fee / 100, // 微信金额单位为分
+ paid_at: new Date()
+ });
+
+ // 返回微信要求的格式
+ res.set('Content-Type', 'application/xml');
+ res.send(`
+
+
+
+
+ `);
+ } catch (error) {
+ console.error('处理微信支付回调错误:', error);
+ res.set('Content-Type', 'application/xml');
+ res.send(`
+
+
+
+
+ `);
+ }
+ }
+
+ /**
+ * 处理支付回调(支付宝)
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async handleAlipayCallback(req, res) {
+ try {
+ const callbackData = req.body;
+
+ // 验证回调数据
+ if (!callbackData.out_trade_no || !callbackData.trade_no) {
+ return res.status(400).json({
+ success: false,
+ message: '回调数据不完整'
+ });
+ }
+
+ // 处理支付回调
+ const payment = await PaymentService.handlePaymentCallback({
+ payment_no: callbackData.out_trade_no,
+ transaction_id: callbackData.trade_no,
+ status: callbackData.trade_status === 'TRADE_SUCCESS' ? 'paid' : 'failed',
+ paid_amount: parseFloat(callbackData.total_amount),
+ paid_at: new Date()
+ });
+
+ res.send('success');
+ } catch (error) {
+ console.error('处理支付宝回调错误:', error);
+ res.send('fail');
+ }
+ }
+
+ /**
+ * 申请退款
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async createRefund(req, res) {
+ try {
+ const { paymentId } = req.params;
+ const refundData = req.body;
+ const userId = req.user.id;
+
+ // 验证必要字段
+ if (!refundData.refund_amount || !refundData.refund_reason) {
+ return res.status(400).json({
+ success: false,
+ message: '缺少必要字段: refund_amount, refund_reason'
+ });
+ }
+
+ // 获取支付订单并验证权限
+ const payment = await PaymentService.getPaymentById(paymentId);
+ if (req.user.role === 'user' && payment.user_id !== userId) {
+ return res.status(403).json({
+ success: false,
+ message: '无权操作此支付订单'
+ });
+ }
+
+ const refund = await PaymentService.createRefund({
+ payment_id: paymentId,
+ refund_amount: refundData.refund_amount,
+ refund_reason: refundData.refund_reason,
+ user_id: userId
+ });
+
+ res.status(201).json({
+ success: true,
+ message: '退款申请提交成功',
+ data: refund
+ });
+ } catch (error) {
+ console.error('申请退款控制器错误:', error);
+ res.status(500).json({
+ success: false,
+ message: error.message || '申请退款失败'
+ });
+ }
+ }
+
+ /**
+ * 处理退款(管理员)
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async processRefund(req, res) {
+ try {
+ const { refundId } = req.params;
+ const { status, process_remark } = req.body;
+ const adminId = req.user.id;
+
+ // 验证状态
+ const validStatuses = ['approved', 'rejected', 'completed'];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({
+ success: false,
+ message: '无效的退款状态'
+ });
+ }
+
+ const refund = await PaymentService.processRefund(refundId, status, {
+ processed_by: adminId,
+ process_remark
+ });
+
+ res.json({
+ success: true,
+ message: '退款处理成功',
+ data: refund
+ });
+ } catch (error) {
+ console.error('处理退款控制器错误:', error);
+ res.status(500).json({
+ success: false,
+ message: error.message || '处理退款失败'
+ });
+ }
+ }
+
+ /**
+ * 获取退款详情
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async getRefund(req, res) {
+ try {
+ const { refundId } = req.params;
+ const userId = req.user.id;
+
+ const refund = await PaymentService.getRefundById(refundId);
+
+ // 检查权限
+ if (req.user.role === 'user' && refund.user_id !== userId) {
+ return res.status(403).json({
+ success: false,
+ message: '无权访问此退款记录'
+ });
+ }
+
+ res.json({
+ success: true,
+ data: refund
+ });
+ } catch (error) {
+ console.error('获取退款详情控制器错误:', error);
+ if (error.message === '退款记录不存在') {
+ return res.status(404).json({
+ success: false,
+ message: '退款记录不存在'
+ });
+ }
+ res.status(500).json({
+ success: false,
+ message: error.message || '获取退款详情失败'
+ });
+ }
+ }
+
+ /**
+ * 获取支付统计信息(管理员)
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+ async getPaymentStatistics(req, res) {
+ try {
+ const filters = {
+ start_date: req.query.start_date,
+ end_date: req.query.end_date,
+ payment_method: req.query.payment_method
+ };
+
+ const statistics = await PaymentService.getPaymentStatistics(filters);
+
+ res.json({
+ success: true,
+ data: statistics
+ });
+ } catch (error) {
+ console.error('获取支付统计控制器错误:', error);
+ res.status(500).json({
+ success: false,
+ message: error.message || '获取支付统计失败'
+ });
+ }
+ }
+}
+
+module.exports = new PaymentController();
\ No newline at end of file
diff --git a/backend/src/controllers/travelRegistration.js b/backend/src/controllers/travelRegistration.js
new file mode 100644
index 0000000..b21330c
--- /dev/null
+++ b/backend/src/controllers/travelRegistration.js
@@ -0,0 +1,163 @@
+const TravelRegistrationService = require('../services/travelRegistration');
+const { success } = require('../utils/response');
+const { AppError } = require('../utils/errors');
+
+/**
+ * 旅行活动报名控制器
+ */
+class TravelRegistrationController {
+ /**
+ * 报名参加旅行活动
+ */
+ static async registerForTravel(req, res, next) {
+ try {
+ const { travelId } = req.params;
+ const { message, emergencyContact, emergencyPhone } = req.body;
+ const userId = req.userId;
+
+ if (!travelId) {
+ throw new AppError('旅行活动ID不能为空', 400);
+ }
+
+ const registration = await TravelRegistrationService.registerForTravel({
+ userId,
+ travelId: parseInt(travelId),
+ message,
+ emergencyContact,
+ emergencyPhone
+ });
+
+ res.json(success({
+ registration,
+ message: '报名成功,等待审核'
+ }));
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * 取消报名
+ */
+ static async cancelRegistration(req, res, next) {
+ try {
+ const { registrationId } = req.params;
+ const userId = req.userId;
+
+ if (!registrationId) {
+ throw new AppError('报名记录ID不能为空', 400);
+ }
+
+ await TravelRegistrationService.cancelRegistration(parseInt(registrationId), userId);
+
+ res.json(success({
+ message: '取消报名成功'
+ }));
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * 获取用户的报名记录
+ */
+ static async getUserRegistrations(req, res, next) {
+ try {
+ const { page, pageSize, status } = req.query;
+ const userId = req.userId;
+
+ const result = await TravelRegistrationService.getUserRegistrations({
+ userId,
+ page: parseInt(page) || 1,
+ pageSize: parseInt(pageSize) || 10,
+ status
+ });
+
+ res.json(success(result));
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * 获取旅行活动的报名列表(活动发起者可查看)
+ */
+ static async getTravelRegistrations(req, res, next) {
+ try {
+ const { travelId } = req.params;
+ const { page, pageSize, status } = req.query;
+ const userId = req.userId;
+
+ if (!travelId) {
+ throw new AppError('旅行活动ID不能为空', 400);
+ }
+
+ const result = await TravelRegistrationService.getTravelRegistrations({
+ travelId: parseInt(travelId),
+ organizerId: userId,
+ page: parseInt(page) || 1,
+ pageSize: parseInt(pageSize) || 10,
+ status
+ });
+
+ res.json(success(result));
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * 审核报名申请(活动发起者操作)
+ */
+ static async reviewRegistration(req, res, next) {
+ try {
+ const { registrationId } = req.params;
+ const { action, rejectReason } = req.body;
+ const userId = req.userId;
+
+ if (!registrationId) {
+ throw new AppError('报名记录ID不能为空', 400);
+ }
+
+ if (!['approve', 'reject'].includes(action)) {
+ throw new AppError('操作类型无效', 400);
+ }
+
+ const result = await TravelRegistrationService.reviewRegistration({
+ registrationId: parseInt(registrationId),
+ organizerId: userId,
+ action,
+ rejectReason
+ });
+
+ res.json(success({
+ registration: result,
+ message: action === 'approve' ? '审核通过' : '已拒绝申请'
+ }));
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * 获取报名统计信息
+ */
+ static async getRegistrationStats(req, res, next) {
+ try {
+ const { travelId } = req.params;
+ const userId = req.userId;
+
+ if (!travelId) {
+ throw new AppError('旅行活动ID不能为空', 400);
+ }
+
+ const stats = await TravelRegistrationService.getRegistrationStats(parseInt(travelId), userId);
+
+ res.json(success({ stats }));
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = TravelRegistrationController;
\ No newline at end of file
diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js
new file mode 100644
index 0000000..c7e3e14
--- /dev/null
+++ b/backend/src/middleware/errorHandler.js
@@ -0,0 +1,261 @@
+/**
+ * 统一错误处理中间件
+ * 处理应用程序中的所有错误,提供统一的错误响应格式
+ */
+
+const logger = require('../utils/logger');
+
+/**
+ * 自定义错误类
+ */
+class AppError extends Error {
+ constructor(message, statusCode, errorCode = null) {
+ super(message);
+ this.statusCode = statusCode;
+ this.errorCode = errorCode;
+ this.isOperational = true;
+
+ Error.captureStackTrace(this, this.constructor);
+ }
+}
+
+/**
+ * 异步错误捕获包装器
+ * @param {Function} fn - 异步函数
+ * @returns {Function} 包装后的函数
+ */
+const catchAsync = (fn) => {
+ return (req, res, next) => {
+ fn(req, res, next).catch(next);
+ };
+};
+
+/**
+ * 处理数据库错误
+ * @param {Error} err - 数据库错误
+ * @returns {AppError} 应用错误
+ */
+const handleDatabaseError = (err) => {
+ if (err.code === 'ER_DUP_ENTRY') {
+ return new AppError('数据已存在,请检查输入信息', 400, 'DUPLICATE_ENTRY');
+ }
+
+ if (err.code === 'ER_NO_REFERENCED_ROW_2') {
+ return new AppError('关联数据不存在', 400, 'FOREIGN_KEY_CONSTRAINT');
+ }
+
+ if (err.code === 'ER_ROW_IS_REFERENCED_2') {
+ return new AppError('数据正在被使用,无法删除', 400, 'REFERENCED_DATA');
+ }
+
+ if (err.code === 'ER_DATA_TOO_LONG') {
+ return new AppError('输入数据过长', 400, 'DATA_TOO_LONG');
+ }
+
+ if (err.code === 'ER_BAD_NULL_ERROR') {
+ return new AppError('必填字段不能为空', 400, 'REQUIRED_FIELD_MISSING');
+ }
+
+ return new AppError('数据库操作失败', 500, 'DATABASE_ERROR');
+};
+
+/**
+ * 处理JWT错误
+ * @param {Error} err - JWT错误
+ * @returns {AppError} 应用错误
+ */
+const handleJWTError = (err) => {
+ if (err.name === 'JsonWebTokenError') {
+ return new AppError('无效的访问令牌', 401, 'INVALID_TOKEN');
+ }
+
+ if (err.name === 'TokenExpiredError') {
+ return new AppError('访问令牌已过期', 401, 'TOKEN_EXPIRED');
+ }
+
+ return new AppError('令牌验证失败', 401, 'TOKEN_VERIFICATION_FAILED');
+};
+
+/**
+ * 处理验证错误
+ * @param {Error} err - 验证错误
+ * @returns {AppError} 应用错误
+ */
+const handleValidationError = (err) => {
+ if (err.name === 'ValidationError') {
+ const errors = Object.values(err.errors).map(e => e.message);
+ return new AppError(`数据验证失败: ${errors.join(', ')}`, 400, 'VALIDATION_ERROR');
+ }
+
+ return new AppError('数据格式错误', 400, 'INVALID_DATA_FORMAT');
+};
+
+/**
+ * 处理文件上传错误
+ * @param {Error} err - 文件上传错误
+ * @returns {AppError} 应用错误
+ */
+const handleFileUploadError = (err) => {
+ if (err.code === 'LIMIT_FILE_SIZE') {
+ return new AppError('文件大小超出限制', 400, 'FILE_TOO_LARGE');
+ }
+
+ if (err.code === 'LIMIT_FILE_COUNT') {
+ return new AppError('文件数量超出限制', 400, 'TOO_MANY_FILES');
+ }
+
+ if (err.code === 'LIMIT_UNEXPECTED_FILE') {
+ return new AppError('不支持的文件类型', 400, 'UNSUPPORTED_FILE_TYPE');
+ }
+
+ return new AppError('文件上传失败', 400, 'FILE_UPLOAD_ERROR');
+};
+
+/**
+ * 发送错误响应
+ * @param {Error} err - 错误对象
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ */
+const sendErrorResponse = (err, req, res) => {
+ const { statusCode, message, errorCode } = err;
+
+ // 构建错误响应
+ const errorResponse = {
+ success: false,
+ message: message || '服务器内部错误',
+ error_code: errorCode || 'INTERNAL_ERROR',
+ timestamp: new Date().toISOString(),
+ path: req.originalUrl,
+ method: req.method
+ };
+
+ // 开发环境下包含错误堆栈
+ if (process.env.NODE_ENV === 'development') {
+ errorResponse.stack = err.stack;
+ errorResponse.details = err;
+ }
+
+ // 记录错误日志
+ logger.error('API Error:', {
+ message: err.message,
+ statusCode,
+ errorCode,
+ path: req.originalUrl,
+ method: req.method,
+ userAgent: req.get('User-Agent'),
+ ip: req.ip,
+ userId: req.user?.id,
+ stack: err.stack
+ });
+
+ res.status(statusCode).json(errorResponse);
+};
+
+/**
+ * 全局错误处理中间件
+ * @param {Error} err - 错误对象
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+const globalErrorHandler = (err, req, res, next) => {
+ // 设置默认错误状态码
+ err.statusCode = err.statusCode || 500;
+
+ let error = { ...err };
+ error.message = err.message;
+
+ // 处理不同类型的错误
+ if (err.code && err.code.startsWith('ER_')) {
+ error = handleDatabaseError(err);
+ } else if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
+ error = handleJWTError(err);
+ } else if (err.name === 'ValidationError') {
+ error = handleValidationError(err);
+ } else if (err.code && err.code.startsWith('LIMIT_')) {
+ error = handleFileUploadError(err);
+ } else if (err.name === 'CastError') {
+ error = new AppError('无效的数据格式', 400, 'INVALID_DATA_FORMAT');
+ } else if (err.code === 'ENOENT') {
+ error = new AppError('文件不存在', 404, 'FILE_NOT_FOUND');
+ } else if (err.code === 'EACCES') {
+ error = new AppError('文件访问权限不足', 403, 'FILE_ACCESS_DENIED');
+ }
+
+ // 如果不是操作性错误,设置为服务器错误
+ if (!error.isOperational) {
+ error.statusCode = 500;
+ error.message = '服务器内部错误';
+ error.errorCode = 'INTERNAL_ERROR';
+ }
+
+ sendErrorResponse(error, req, res);
+};
+
+/**
+ * 处理未找到的路由
+ * @param {Object} req - 请求对象
+ * @param {Object} res - 响应对象
+ * @param {Function} next - 下一个中间件
+ */
+const notFoundHandler = (req, res, next) => {
+ const err = new AppError(`路由 ${req.originalUrl} 不存在`, 404, 'ROUTE_NOT_FOUND');
+ next(err);
+};
+
+/**
+ * 处理未捕获的Promise拒绝
+ */
+process.on('unhandledRejection', (reason, promise) => {
+ logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
+ // 优雅关闭服务器
+ process.exit(1);
+});
+
+/**
+ * 处理未捕获的异常
+ */
+process.on('uncaughtException', (err) => {
+ logger.error('Uncaught Exception:', err);
+ // 优雅关闭服务器
+ process.exit(1);
+});
+
+/**
+ * 常用错误类型
+ */
+const ErrorTypes = {
+ // 认证相关
+ UNAUTHORIZED: (message = '未授权访问') => new AppError(message, 401, 'UNAUTHORIZED'),
+ FORBIDDEN: (message = '权限不足') => new AppError(message, 403, 'FORBIDDEN'),
+ TOKEN_EXPIRED: (message = '访问令牌已过期') => new AppError(message, 401, 'TOKEN_EXPIRED'),
+
+ // 数据相关
+ NOT_FOUND: (message = '资源不存在') => new AppError(message, 404, 'NOT_FOUND'),
+ DUPLICATE_ENTRY: (message = '数据已存在') => new AppError(message, 400, 'DUPLICATE_ENTRY'),
+ VALIDATION_ERROR: (message = '数据验证失败') => new AppError(message, 400, 'VALIDATION_ERROR'),
+
+ // 业务相关
+ BUSINESS_ERROR: (message = '业务处理失败') => new AppError(message, 400, 'BUSINESS_ERROR'),
+ INSUFFICIENT_BALANCE: (message = '余额不足') => new AppError(message, 400, 'INSUFFICIENT_BALANCE'),
+ OPERATION_NOT_ALLOWED: (message = '操作不被允许') => new AppError(message, 400, 'OPERATION_NOT_ALLOWED'),
+
+ // 系统相关
+ INTERNAL_ERROR: (message = '服务器内部错误') => new AppError(message, 500, 'INTERNAL_ERROR'),
+ SERVICE_UNAVAILABLE: (message = '服务暂不可用') => new AppError(message, 503, 'SERVICE_UNAVAILABLE'),
+ RATE_LIMIT_EXCEEDED: (message = '请求频率超出限制') => new AppError(message, 429, 'RATE_LIMIT_EXCEEDED'),
+
+ // 文件相关
+ FILE_TOO_LARGE: (message = '文件大小超出限制') => new AppError(message, 400, 'FILE_TOO_LARGE'),
+ UNSUPPORTED_FILE_TYPE: (message = '不支持的文件类型') => new AppError(message, 400, 'UNSUPPORTED_FILE_TYPE'),
+ FILE_UPLOAD_ERROR: (message = '文件上传失败') => new AppError(message, 400, 'FILE_UPLOAD_ERROR')
+};
+
+module.exports = {
+ AppError,
+ catchAsync,
+ globalErrorHandler,
+ notFoundHandler,
+ ErrorTypes
+};
\ No newline at end of file
diff --git a/backend/src/middleware/upload.js b/backend/src/middleware/upload.js
new file mode 100644
index 0000000..a67271a
--- /dev/null
+++ b/backend/src/middleware/upload.js
@@ -0,0 +1,488 @@
+/**
+ * 文件上传中间件
+ * 支持图片上传、文件类型验证、大小限制等功能
+ */
+
+const multer = require('multer');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const sharp = require('sharp');
+const { AppError, ErrorTypes } = require('./errorHandler');
+const { logSystemEvent, logError } = require('../utils/logger');
+
+// 确保上传目录存在
+const uploadDir = path.join(__dirname, '../../uploads');
+const avatarDir = path.join(uploadDir, 'avatars');
+const animalDir = path.join(uploadDir, 'animals');
+const travelDir = path.join(uploadDir, 'travels');
+const documentDir = path.join(uploadDir, 'documents');
+
+[uploadDir, avatarDir, animalDir, travelDir, documentDir].forEach(dir => {
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+});
+
+/**
+ * 生成唯一文件名
+ * @param {string} originalName - 原始文件名
+ * @returns {string} 唯一文件名
+ */
+const generateUniqueFileName = (originalName) => {
+ const timestamp = Date.now();
+ const randomString = crypto.randomBytes(8).toString('hex');
+ const ext = path.extname(originalName).toLowerCase();
+ return `${timestamp}_${randomString}${ext}`;
+};
+
+/**
+ * 获取文件存储目录
+ * @param {string} type - 文件类型
+ * @returns {string} 存储目录路径
+ */
+const getStorageDir = (type) => {
+ switch (type) {
+ case 'avatar':
+ return avatarDir;
+ case 'animal':
+ return animalDir;
+ case 'travel':
+ return travelDir;
+ case 'document':
+ return documentDir;
+ default:
+ return uploadDir;
+ }
+};
+
+/**
+ * 文件过滤器
+ * @param {string} type - 文件类型
+ * @returns {Function} 过滤器函数
+ */
+const createFileFilter = (type) => {
+ return (req, file, cb) => {
+ try {
+ let allowedTypes = [];
+ let allowedMimes = [];
+
+ switch (type) {
+ case 'image':
+ allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
+ allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+ break;
+ case 'document':
+ allowedTypes = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt'];
+ allowedMimes = [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'text/plain'
+ ];
+ break;
+ case 'avatar':
+ allowedTypes = ['.jpg', '.jpeg', '.png'];
+ allowedMimes = ['image/jpeg', 'image/png'];
+ break;
+ default:
+ allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.doc', '.docx'];
+ allowedMimes = [
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp',
+ 'application/pdf', 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ ];
+ }
+
+ const fileExt = path.extname(file.originalname).toLowerCase();
+
+ if (allowedTypes.includes(fileExt) && allowedMimes.includes(file.mimetype)) {
+ cb(null, true);
+ } else {
+ cb(new AppError(`不支持的文件类型。允许的类型: ${allowedTypes.join(', ')}`, 400, 'UNSUPPORTED_FILE_TYPE'));
+ }
+ } catch (error) {
+ cb(error);
+ }
+ };
+};
+
+/**
+ * 创建存储配置
+ * @param {string} type - 文件类型
+ * @returns {Object} 存储配置
+ */
+const createStorage = (type) => {
+ return multer.diskStorage({
+ destination: (req, file, cb) => {
+ const dir = getStorageDir(type);
+ cb(null, dir);
+ },
+ filename: (req, file, cb) => {
+ const uniqueName = generateUniqueFileName(file.originalname);
+ cb(null, uniqueName);
+ }
+ });
+};
+
+/**
+ * 创建上传中间件
+ * @param {Object} options - 配置选项
+ * @returns {Function} 上传中间件
+ */
+const createUploadMiddleware = (options = {}) => {
+ const {
+ type = 'image',
+ maxSize = 5 * 1024 * 1024, // 5MB
+ maxFiles = 1,
+ fieldName = 'file'
+ } = options;
+
+ const upload = multer({
+ storage: createStorage(type),
+ fileFilter: createFileFilter(type),
+ limits: {
+ fileSize: maxSize,
+ files: maxFiles
+ }
+ });
+
+ return (req, res, next) => {
+ const uploadHandler = maxFiles === 1 ? upload.single(fieldName) : upload.array(fieldName, maxFiles);
+
+ uploadHandler(req, res, (err) => {
+ if (err) {
+ logError(err, {
+ type: 'file_upload_error',
+ userId: req.user?.id,
+ fieldName,
+ maxSize,
+ maxFiles
+ });
+
+ if (err instanceof multer.MulterError) {
+ if (err.code === 'LIMIT_FILE_SIZE') {
+ return next(ErrorTypes.FILE_TOO_LARGE(`文件大小不能超过 ${Math.round(maxSize / 1024 / 1024)}MB`));
+ } else if (err.code === 'LIMIT_FILE_COUNT') {
+ return next(ErrorTypes.FILE_UPLOAD_ERROR(`文件数量不能超过 ${maxFiles} 个`));
+ } else if (err.code === 'LIMIT_UNEXPECTED_FILE') {
+ return next(ErrorTypes.UNSUPPORTED_FILE_TYPE('不支持的文件字段'));
+ }
+ }
+
+ return next(err);
+ }
+
+ // 记录上传成功日志
+ if (req.file || req.files) {
+ const files = req.files || [req.file];
+ logSystemEvent('file_uploaded', {
+ userId: req.user?.id,
+ fileCount: files.length,
+ files: files.map(f => ({
+ originalName: f.originalname,
+ filename: f.filename,
+ size: f.size,
+ mimetype: f.mimetype
+ }))
+ });
+ }
+
+ next();
+ });
+ };
+};
+
+/**
+ * 图片处理中间件
+ * @param {Object} options - 处理选项
+ * @returns {Function} 处理中间件
+ */
+const processImage = (options = {}) => {
+ return async (req, res, next) => {
+ try {
+ if (!req.file && !req.files) {
+ return next();
+ }
+
+ const files = req.files || [req.file];
+ const processedFiles = [];
+
+ for (const file of files) {
+ // 只处理图片文件
+ if (!file.mimetype.startsWith('image/')) {
+ processedFiles.push(file);
+ continue;
+ }
+
+ const {
+ width = null,
+ height = null,
+ quality = 80,
+ format = 'jpeg',
+ thumbnail = false,
+ thumbnailSize = 200
+ } = options;
+
+ const inputPath = file.path;
+ const outputPath = inputPath.replace(path.extname(inputPath), `.${format}`);
+
+ let sharpInstance = sharp(inputPath);
+
+ // 调整尺寸
+ if (width || height) {
+ sharpInstance = sharpInstance.resize(width, height, {
+ fit: 'inside',
+ withoutEnlargement: true
+ });
+ }
+
+ // 设置质量和格式
+ if (format === 'jpeg') {
+ sharpInstance = sharpInstance.jpeg({ quality });
+ } else if (format === 'png') {
+ sharpInstance = sharpInstance.png({ quality });
+ } else if (format === 'webp') {
+ sharpInstance = sharpInstance.webp({ quality });
+ }
+
+ // 保存处理后的图片
+ await sharpInstance.toFile(outputPath);
+
+ // 删除原始文件(如果格式不同)
+ if (inputPath !== outputPath) {
+ fs.unlinkSync(inputPath);
+ }
+
+ // 更新文件信息
+ file.path = outputPath;
+ file.filename = path.basename(outputPath);
+
+ // 生成缩略图
+ if (thumbnail) {
+ const thumbnailPath = outputPath.replace(
+ path.extname(outputPath),
+ `_thumb${path.extname(outputPath)}`
+ );
+
+ await sharp(outputPath)
+ .resize(thumbnailSize, thumbnailSize, {
+ fit: 'cover',
+ position: 'center'
+ })
+ .jpeg({ quality: 70 })
+ .toFile(thumbnailPath);
+
+ file.thumbnail = path.basename(thumbnailPath);
+ }
+
+ processedFiles.push(file);
+ }
+
+ // 更新请求对象
+ if (req.files) {
+ req.files = processedFiles;
+ } else {
+ req.file = processedFiles[0];
+ }
+
+ next();
+ } catch (error) {
+ logError(error, {
+ type: 'image_processing_error',
+ userId: req.user?.id,
+ options
+ });
+ next(ErrorTypes.FILE_UPLOAD_ERROR('图片处理失败'));
+ }
+ };
+};
+
+/**
+ * 删除文件
+ * @param {string} filePath - 文件路径
+ * @returns {Promise} 删除结果
+ */
+const deleteFile = async (filePath) => {
+ try {
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(uploadDir, filePath);
+
+ if (fs.existsSync(fullPath)) {
+ fs.unlinkSync(fullPath);
+
+ // 同时删除缩略图
+ const thumbnailPath = fullPath.replace(
+ path.extname(fullPath),
+ `_thumb${path.extname(fullPath)}`
+ );
+ if (fs.existsSync(thumbnailPath)) {
+ fs.unlinkSync(thumbnailPath);
+ }
+
+ logSystemEvent('file_deleted', { filePath: fullPath });
+ return true;
+ }
+
+ return false;
+ } catch (error) {
+ logError(error, { type: 'file_deletion_error', filePath });
+ return false;
+ }
+};
+
+/**
+ * 获取文件信息
+ * @param {string} filePath - 文件路径
+ * @returns {Object|null} 文件信息
+ */
+const getFileInfo = (filePath) => {
+ try {
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(uploadDir, filePath);
+
+ if (!fs.existsSync(fullPath)) {
+ return null;
+ }
+
+ const stats = fs.statSync(fullPath);
+ const ext = path.extname(fullPath).toLowerCase();
+
+ return {
+ path: filePath,
+ size: stats.size,
+ created: stats.birthtime,
+ modified: stats.mtime,
+ extension: ext,
+ isImage: ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)
+ };
+ } catch (error) {
+ logError(error, { type: 'file_info_error', filePath });
+ return null;
+ }
+};
+
+/**
+ * 清理临时文件
+ * @param {number} maxAge - 最大存在时间(毫秒)
+ */
+const cleanupTempFiles = (maxAge = 24 * 60 * 60 * 1000) => {
+ const tempDir = path.join(uploadDir, 'temp');
+
+ if (!fs.existsSync(tempDir)) {
+ return;
+ }
+
+ fs.readdir(tempDir, (err, files) => {
+ if (err) {
+ logError(err, { type: 'temp_cleanup_error' });
+ return;
+ }
+
+ const now = Date.now();
+
+ files.forEach(file => {
+ const filePath = path.join(tempDir, file);
+ fs.stat(filePath, (err, stats) => {
+ if (err) return;
+
+ if (now - stats.mtime.getTime() > maxAge) {
+ fs.unlink(filePath, (err) => {
+ if (err) {
+ logError(err, { type: 'temp_file_deletion_error', filePath });
+ } else {
+ logSystemEvent('temp_file_cleaned', { filePath });
+ }
+ });
+ }
+ });
+ });
+ });
+};
+
+// 每小时清理一次临时文件
+setInterval(cleanupTempFiles, 60 * 60 * 1000);
+
+/**
+ * 预定义的上传中间件
+ */
+const uploadMiddlewares = {
+ // 头像上传
+ avatar: createUploadMiddleware({
+ type: 'avatar',
+ maxSize: 2 * 1024 * 1024, // 2MB
+ maxFiles: 1,
+ fieldName: 'avatar'
+ }),
+
+ // 动物图片上传
+ animalImages: createUploadMiddleware({
+ type: 'animal',
+ maxSize: 5 * 1024 * 1024, // 5MB
+ maxFiles: 5,
+ fieldName: 'images'
+ }),
+
+ // 旅行图片上传
+ travelImages: createUploadMiddleware({
+ type: 'travel',
+ maxSize: 5 * 1024 * 1024, // 5MB
+ maxFiles: 10,
+ fieldName: 'images'
+ }),
+
+ // 文档上传
+ documents: createUploadMiddleware({
+ type: 'document',
+ maxSize: 10 * 1024 * 1024, // 10MB
+ maxFiles: 3,
+ fieldName: 'documents'
+ })
+};
+
+/**
+ * 预定义的图片处理中间件
+ */
+const imageProcessors = {
+ // 头像处理
+ avatar: processImage({
+ width: 300,
+ height: 300,
+ quality: 85,
+ format: 'jpeg',
+ thumbnail: true,
+ thumbnailSize: 100
+ }),
+
+ // 动物图片处理
+ animal: processImage({
+ width: 800,
+ height: 600,
+ quality: 80,
+ format: 'jpeg',
+ thumbnail: true,
+ thumbnailSize: 200
+ }),
+
+ // 旅行图片处理
+ travel: processImage({
+ width: 1200,
+ height: 800,
+ quality: 80,
+ format: 'jpeg',
+ thumbnail: true,
+ thumbnailSize: 300
+ })
+};
+
+module.exports = {
+ createUploadMiddleware,
+ processImage,
+ deleteFile,
+ getFileInfo,
+ cleanupTempFiles,
+ uploadMiddlewares,
+ imageProcessors,
+ generateUniqueFileName,
+ getStorageDir
+};
\ No newline at end of file
diff --git a/backend/src/models/AnimalClaim.js b/backend/src/models/AnimalClaim.js
new file mode 100644
index 0000000..3f5c07a
--- /dev/null
+++ b/backend/src/models/AnimalClaim.js
@@ -0,0 +1,582 @@
+const db = require('../config/database');
+
+class AnimalClaim {
+ /**
+ * 创建认领申请
+ * @param {Object} claimData - 认领申请数据
+ * @returns {Object} 创建的认领申请
+ */
+ static async create(claimData) {
+ try {
+ const {
+ claim_no,
+ animal_id,
+ user_id,
+ claim_reason,
+ claim_duration,
+ total_amount,
+ contact_info,
+ status = 'pending'
+ } = claimData;
+
+ const query = `
+ INSERT INTO animal_claims (
+ claim_no, animal_id, user_id, claim_reason, claim_duration,
+ total_amount, contact_info, status, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
+ `;
+
+ const [result] = await db.execute(query, [
+ claim_no,
+ animal_id,
+ user_id,
+ claim_reason,
+ claim_duration,
+ total_amount,
+ contact_info,
+ status
+ ]);
+
+ return await this.findById(result.insertId);
+ } catch (error) {
+ console.error('创建认领申请数据库错误:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 根据ID查找认领申请
+ * @param {number} id - 认领申请ID
+ * @returns {Object|null} 认领申请信息
+ */
+ static async findById(id) {
+ try {
+ const query = `
+ SELECT
+ ac.*,
+ a.name as animal_name,
+ a.type as animal_type,
+ a.image as animal_image,
+ a.price as animal_price,
+ u.username,
+ u.phone as user_phone,
+ reviewer.username as reviewer_name
+ FROM animal_claims ac
+ LEFT JOIN animals a ON ac.animal_id = a.id
+ LEFT JOIN users u ON ac.user_id = u.id
+ LEFT JOIN users reviewer ON ac.reviewed_by = reviewer.id
+ WHERE ac.id = ? AND ac.deleted_at IS NULL
+ `;
+
+ const [rows] = await db.execute(query, [id]);
+ return rows[0] || null;
+ } catch (error) {
+ console.error('查找认领申请数据库错误:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 根据认领订单号查找
+ * @param {string} claimNo - 认领订单号
+ * @returns {Object|null} 认领申请信息
+ */
+ static async findByClaimNo(claimNo) {
+ try {
+ const query = `
+ SELECT
+ ac.*,
+ a.name as animal_name,
+ a.type as animal_type,
+ a.image as animal_image,
+ u.username,
+ u.phone as user_phone
+ FROM animal_claims ac
+ LEFT JOIN animals a ON ac.animal_id = a.id
+ LEFT JOIN users u ON ac.user_id = u.id
+ WHERE ac.claim_no = ? AND ac.deleted_at IS NULL
+ `;
+
+ const [rows] = await db.execute(query, [claimNo]);
+ return rows[0] || null;
+ } catch (error) {
+ console.error('根据订单号查找认领申请数据库错误:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 查找用户对特定动物的活跃认领申请
+ * @param {number} userId - 用户ID
+ * @param {number} animalId - 动物ID
+ * @returns {Object|null} 认领申请信息
+ */
+ static async findActiveClaimByUserAndAnimal(userId, animalId) {
+ try {
+ const query = `
+ SELECT * FROM animal_claims
+ WHERE user_id = ? AND animal_id = ?
+ AND status IN ('pending', 'approved')
+ AND deleted_at IS NULL
+ ORDER BY created_at DESC
+ LIMIT 1
+ `;
+
+ const [rows] = await db.execute(query, [userId, animalId]);
+ return rows[0] || null;
+ } catch (error) {
+ console.error('查找活跃认领申请数据库错误:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 更新认领申请状态
+ * @param {number} id - 认领申请ID
+ * @param {string} status - 新状态
+ * @param {Object} updateData - 更新数据
+ * @returns {Object} 更新后的认领申请
+ */
+ static async updateStatus(id, status, updateData = {}) {
+ try {
+ const fields = ['status = ?', 'updated_at = NOW()'];
+ const values = [status];
+
+ // 动态添加更新字段
+ Object.keys(updateData).forEach(key => {
+ if (updateData[key] !== undefined) {
+ fields.push(`${key} = ?`);
+ values.push(updateData[key]);
+ }
+ });
+
+ values.push(id);
+
+ const query = `
+ UPDATE animal_claims
+ SET ${fields.join(', ')}
+ WHERE id = ?
+ `;
+
+ await db.execute(query, values);
+ return await this.findById(id);
+ } catch (error) {
+ console.error('更新认领申请状态数据库错误:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 获取用户的认领申请列表
+ * @param {number} userId - 用户ID
+ * @param {Object} options - 查询选项
+ * @returns {Object} 分页结果
+ */
+ static async getUserClaims(userId, options = {}) {
+ try {
+ const {
+ page = 1,
+ limit = 10,
+ status,
+ animal_type,
+ start_date,
+ end_date
+ } = options;
+
+ const offset = (page - 1) * limit;
+ let whereConditions = ['ac.user_id = ?', 'ac.deleted_at IS NULL'];
+ let queryParams = [userId];
+
+ // 添加筛选条件
+ if (status) {
+ whereConditions.push('ac.status = ?');
+ queryParams.push(status);
+ }
+
+ if (animal_type) {
+ whereConditions.push('a.type = ?');
+ queryParams.push(animal_type);
+ }
+
+ if (start_date) {
+ whereConditions.push('ac.created_at >= ?');
+ queryParams.push(start_date);
+ }
+
+ if (end_date) {
+ whereConditions.push('ac.created_at <= ?');
+ queryParams.push(end_date);
+ }
+
+ const whereClause = whereConditions.join(' AND ');
+
+ // 查询数据
+ const dataQuery = `
+ SELECT
+ ac.*,
+ a.name as animal_name,
+ a.type as animal_type,
+ a.image as animal_image,
+ a.price as animal_price
+ FROM animal_claims ac
+ LEFT JOIN animals a ON ac.animal_id = a.id
+ WHERE ${whereClause}
+ ORDER BY ac.created_at DESC
+ LIMIT ? OFFSET ?
+ `;
+
+ const [dataRows] = await db.execute(dataQuery, [...queryParams, limit, offset]);
+
+ // 查询总数
+ const countQuery = `
+ SELECT COUNT(*) as total
+ FROM animal_claims ac
+ LEFT JOIN animals a ON ac.animal_id = a.id
+ WHERE ${whereClause}
+ `;
+
+ const [countRows] = await db.execute(countQuery, queryParams);
+ const total = countRows[0].total;
+
+ return {
+ data: dataRows,
+ pagination: {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ total,
+ pages: Math.ceil(total / limit)
+ }
+ };
+ } catch (error) {
+ console.error('获取用户认领申请列表数据库错误:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 获取动物的认领申请列表
+ * @param {number} animalId - 动物ID
+ * @param {Object} options - 查询选项
+ * @returns {Object} 分页结果
+ */
+ static async getAnimalClaims(animalId, options = {}) {
+ try {
+ const {
+ page = 1,
+ limit = 10,
+ status
+ } = options;
+
+ const offset = (page - 1) * limit;
+ let whereConditions = ['ac.animal_id = ?', 'ac.deleted_at IS NULL'];
+ let queryParams = [animalId];
+
+ if (status) {
+ whereConditions.push('ac.status = ?');
+ queryParams.push(status);
+ }
+
+ const whereClause = whereConditions.join(' AND ');
+
+ // 查询数据
+ const dataQuery = `
+ SELECT
+ ac.*,
+ u.username,
+ u.phone as user_phone,
+ u.email as user_email
+ FROM animal_claims ac
+ LEFT JOIN users u ON ac.user_id = u.id
+ WHERE ${whereClause}
+ ORDER BY ac.created_at DESC
+ LIMIT ? OFFSET ?
+ `;
+
+ const [dataRows] = await db.execute(dataQuery, [...queryParams, limit, offset]);
+
+ // 查询总数
+ const countQuery = `
+ SELECT COUNT(*) as total
+ FROM animal_claims ac
+ WHERE ${whereClause}
+ `;
+
+ const [countRows] = await db.execute(countQuery, queryParams);
+ const total = countRows[0].total;
+
+ return {
+ data: dataRows,
+ pagination: {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ total,
+ pages: Math.ceil(total / limit)
+ }
+ };
+ } catch (error) {
+ console.error('获取动物认领申请列表数据库错误:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 获取所有认领申请列表(管理员)
+ * @param {Object} options - 查询选项
+ * @returns {Object} 分页结果
+ */
+ static async getAllClaims(options = {}) {
+ try {
+ const {
+ page = 1,
+ limit = 10,
+ status,
+ animal_type,
+ user_id,
+ start_date,
+ end_date,
+ keyword
+ } = options;
+
+ const offset = (page - 1) * limit;
+ let whereConditions = ['ac.deleted_at IS NULL'];
+ let queryParams = [];
+
+ // 添加筛选条件
+ if (status) {
+ whereConditions.push('ac.status = ?');
+ queryParams.push(status);
+ }
+
+ if (animal_type) {
+ whereConditions.push('a.type = ?');
+ queryParams.push(animal_type);
+ }
+
+ if (user_id) {
+ whereConditions.push('ac.user_id = ?');
+ queryParams.push(user_id);
+ }
+
+ if (start_date) {
+ whereConditions.push('ac.created_at >= ?');
+ queryParams.push(start_date);
+ }
+
+ if (end_date) {
+ whereConditions.push('ac.created_at <= ?');
+ queryParams.push(end_date);
+ }
+
+ if (keyword) {
+ whereConditions.push('(ac.claim_no LIKE ? OR a.name LIKE ? OR u.username LIKE ?)');
+ const keywordPattern = `%${keyword}%`;
+ queryParams.push(keywordPattern, keywordPattern, keywordPattern);
+ }
+
+ const whereClause = whereConditions.join(' AND ');
+
+ // 查询数据
+ const dataQuery = `
+ SELECT
+ ac.*,
+ a.name as animal_name,
+ a.type as animal_type,
+ a.image as animal_image,
+ a.price as animal_price,
+ u.username,
+ u.phone as user_phone,
+ u.email as user_email,
+ reviewer.username as reviewer_name
+ FROM animal_claims ac
+ LEFT JOIN animals a ON ac.animal_id = a.id
+ LEFT JOIN users u ON ac.user_id = u.id
+ LEFT JOIN users reviewer ON ac.reviewed_by = reviewer.id
+ WHERE ${whereClause}
+ ORDER BY ac.created_at DESC
+ LIMIT ? OFFSET ?
+ `;
+
+ const [dataRows] = await db.execute(dataQuery, [...queryParams, limit, offset]);
+
+ // 查询总数
+ const countQuery = `
+ SELECT COUNT(*) as total
+ FROM animal_claims ac
+ LEFT JOIN animals a ON ac.animal_id = a.id
+ LEFT JOIN users u ON ac.user_id = u.id
+ WHERE ${whereClause}
+ `;
+
+ const [countRows] = await db.execute(countQuery, queryParams);
+ const total = countRows[0].total;
+
+ return {
+ data: dataRows,
+ pagination: {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ total,
+ pages: Math.ceil(total / limit)
+ }
+ };
+ } catch (error) {
+ console.error('获取所有认领申请列表数据库错误:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 创建续期记录
+ * @param {Object} renewalData - 续期数据
+ * @returns {Object} 续期记录
+ */
+ static async createRenewal(renewalData) {
+ try {
+ const {
+ claim_id,
+ duration,
+ amount,
+ payment_method,
+ status = 'pending'
+ } = renewalData;
+
+ const query = `
+ INSERT INTO animal_claim_renewals (
+ claim_id, duration, amount, payment_method, status, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, NOW(), NOW())
+ `;
+
+ const [result] = await db.execute(query, [
+ claim_id,
+ duration,
+ amount,
+ payment_method,
+ status
+ ]);
+
+ return {
+ id: result.insertId,
+ claim_id,
+ duration,
+ amount,
+ payment_method,
+ status
+ };
+ } catch (error) {
+ console.error('创建续期记录数据库错误:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 获取认领统计信息
+ * @param {Object} filters - 筛选条件
+ * @returns {Object} 统计信息
+ */
+ static async getClaimStatistics(filters = {}) {
+ try {
+ const { start_date, end_date, animal_type } = filters;
+ let whereConditions = ['ac.deleted_at IS NULL'];
+ let queryParams = [];
+
+ if (start_date) {
+ whereConditions.push('ac.created_at >= ?');
+ queryParams.push(start_date);
+ }
+
+ if (end_date) {
+ whereConditions.push('ac.created_at <= ?');
+ queryParams.push(end_date);
+ }
+
+ if (animal_type) {
+ whereConditions.push('a.type = ?');
+ queryParams.push(animal_type);
+ }
+
+ const whereClause = whereConditions.join(' AND ');
+
+ // 基础统计
+ const basicStatsQuery = `
+ SELECT
+ COUNT(*) as total_claims,
+ COUNT(CASE WHEN ac.status = 'pending' THEN 1 END) as pending_claims,
+ COUNT(CASE WHEN ac.status = 'approved' THEN 1 END) as approved_claims,
+ COUNT(CASE WHEN ac.status = 'rejected' THEN 1 END) as rejected_claims,
+ COUNT(CASE WHEN ac.status = 'cancelled' THEN 1 END) as cancelled_claims,
+ SUM(CASE WHEN ac.status = 'approved' THEN ac.total_amount ELSE 0 END) as total_amount,
+ AVG(CASE WHEN ac.status = 'approved' THEN ac.claim_duration ELSE NULL END) as avg_duration
+ FROM animal_claims ac
+ LEFT JOIN animals a ON ac.animal_id = a.id
+ WHERE ${whereClause}
+ `;
+
+ const [basicStats] = await db.execute(basicStatsQuery, queryParams);
+
+ // 按动物类型统计
+ const typeStatsQuery = `
+ SELECT
+ a.type,
+ COUNT(*) as claim_count,
+ COUNT(CASE WHEN ac.status = 'approved' THEN 1 END) as approved_count,
+ SUM(CASE WHEN ac.status = 'approved' THEN ac.total_amount ELSE 0 END) as total_amount
+ FROM animal_claims ac
+ LEFT JOIN animals a ON ac.animal_id = a.id
+ WHERE ${whereClause}
+ GROUP BY a.type
+ ORDER BY claim_count DESC
+ `;
+
+ const [typeStats] = await db.execute(typeStatsQuery, queryParams);
+
+ // 按月份统计
+ const monthlyStatsQuery = `
+ SELECT
+ DATE_FORMAT(ac.created_at, '%Y-%m') as month,
+ COUNT(*) as claim_count,
+ COUNT(CASE WHEN ac.status = 'approved' THEN 1 END) as approved_count,
+ SUM(CASE WHEN ac.status = 'approved' THEN ac.total_amount ELSE 0 END) as total_amount
+ FROM animal_claims ac
+ LEFT JOIN animals a ON ac.animal_id = a.id
+ WHERE ${whereClause}
+ GROUP BY DATE_FORMAT(ac.created_at, '%Y-%m')
+ ORDER BY month DESC
+ LIMIT 12
+ `;
+
+ const [monthlyStats] = await db.execute(monthlyStatsQuery, queryParams);
+
+ return {
+ basic: basicStats[0],
+ by_type: typeStats,
+ by_month: monthlyStats
+ };
+ } catch (error) {
+ console.error('获取认领统计信息数据库错误:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 软删除认领申请
+ * @param {number} id - 认领申请ID
+ * @returns {boolean} 删除结果
+ */
+ static async softDelete(id) {
+ try {
+ const query = `
+ UPDATE animal_claims
+ SET deleted_at = NOW(), updated_at = NOW()
+ WHERE id = ?
+ `;
+
+ const [result] = await db.execute(query, [id]);
+ return result.affectedRows > 0;
+ } catch (error) {
+ console.error('软删除认领申请数据库错误:', error);
+ throw error;
+ }
+ }
+}
+
+module.exports = AnimalClaim;
\ No newline at end of file
diff --git a/backend/src/models/Payment.js b/backend/src/models/Payment.js
new file mode 100644
index 0000000..4348d1c
--- /dev/null
+++ b/backend/src/models/Payment.js
@@ -0,0 +1,499 @@
+const db = require('../config/database');
+
+class Payment {
+ /**
+ * 创建支付订单
+ * @param {Object} paymentData - 支付订单数据
+ * @returns {Object} 创建的支付订单
+ */
+ static async create(paymentData) {
+ const {
+ payment_no,
+ order_id,
+ user_id,
+ amount,
+ payment_method,
+ return_url,
+ notify_url
+ } = paymentData;
+
+ const query = `
+ INSERT INTO payments (
+ payment_no, order_id, user_id, amount, payment_method,
+ return_url, notify_url, status, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', NOW(), NOW())
+ `;
+
+ const [result] = await db.execute(query, [
+ payment_no, order_id, user_id, amount, payment_method,
+ return_url, notify_url
+ ]);
+
+ return this.findById(result.insertId);
+ }
+
+ /**
+ * 根据ID查找支付订单
+ * @param {number} id - 支付订单ID
+ * @returns {Object|null} 支付订单信息
+ */
+ static async findById(id) {
+ const query = `
+ SELECT p.*, o.order_no, u.username, u.phone
+ FROM payments p
+ LEFT JOIN orders o ON p.order_id = o.id
+ LEFT JOIN users u ON p.user_id = u.id
+ WHERE p.id = ? AND p.deleted_at IS NULL
+ `;
+
+ const [rows] = await db.execute(query, [id]);
+ return rows[0] || null;
+ }
+
+ /**
+ * 根据支付订单号查找支付订单
+ * @param {string} paymentNo - 支付订单号
+ * @returns {Object|null} 支付订单信息
+ */
+ static async findByPaymentNo(paymentNo) {
+ const query = `
+ SELECT p.*, o.order_no, u.username, u.phone
+ FROM payments p
+ LEFT JOIN orders o ON p.order_id = o.id
+ LEFT JOIN users u ON p.user_id = u.id
+ WHERE p.payment_no = ? AND p.deleted_at IS NULL
+ `;
+
+ const [rows] = await db.execute(query, [paymentNo]);
+ return rows[0] || null;
+ }
+
+ /**
+ * 根据订单ID查找支付订单
+ * @param {number} orderId - 订单ID
+ * @returns {Array} 支付订单列表
+ */
+ static async findByOrderId(orderId) {
+ const query = `
+ SELECT * FROM payments
+ WHERE order_id = ? AND deleted_at IS NULL
+ ORDER BY created_at DESC
+ `;
+
+ const [rows] = await db.execute(query, [orderId]);
+ return rows;
+ }
+
+ /**
+ * 更新支付状态
+ * @param {number} id - 支付订单ID
+ * @param {Object} updateData - 更新数据
+ * @returns {Object} 更新后的支付订单
+ */
+ static async updateStatus(id, updateData) {
+ const {
+ status,
+ transaction_id,
+ paid_amount,
+ paid_at,
+ failure_reason
+ } = updateData;
+
+ const query = `
+ UPDATE payments
+ SET status = ?, transaction_id = ?, paid_amount = ?,
+ paid_at = ?, failure_reason = ?, updated_at = NOW()
+ WHERE id = ? AND deleted_at IS NULL
+ `;
+
+ await db.execute(query, [
+ status, transaction_id, paid_amount,
+ paid_at, failure_reason, id
+ ]);
+
+ return this.findById(id);
+ }
+
+ /**
+ * 获取用户支付订单列表
+ * @param {number} userId - 用户ID
+ * @param {Object} options - 查询选项
+ * @returns {Object} 分页结果
+ */
+ static async getUserPayments(userId, options = {}) {
+ const {
+ page = 1,
+ limit = 10,
+ status,
+ payment_method,
+ start_date,
+ end_date
+ } = options;
+
+ const offset = (page - 1) * limit;
+ let whereConditions = ['p.user_id = ?', 'p.deleted_at IS NULL'];
+ let params = [userId];
+
+ // 添加筛选条件
+ if (status) {
+ whereConditions.push('p.status = ?');
+ params.push(status);
+ }
+
+ if (payment_method) {
+ whereConditions.push('p.payment_method = ?');
+ params.push(payment_method);
+ }
+
+ if (start_date) {
+ whereConditions.push('DATE(p.created_at) >= ?');
+ params.push(start_date);
+ }
+
+ if (end_date) {
+ whereConditions.push('DATE(p.created_at) <= ?');
+ params.push(end_date);
+ }
+
+ const whereClause = whereConditions.join(' AND ');
+
+ // 查询总数
+ const countQuery = `
+ SELECT COUNT(*) as total
+ FROM payments p
+ WHERE ${whereClause}
+ `;
+ const [countResult] = await db.execute(countQuery, params);
+ const total = countResult[0].total;
+
+ // 查询数据
+ const dataQuery = `
+ SELECT p.*, o.order_no, o.title as order_title
+ FROM payments p
+ LEFT JOIN orders o ON p.order_id = o.id
+ WHERE ${whereClause}
+ ORDER BY p.created_at DESC
+ LIMIT ? OFFSET ?
+ `;
+ params.push(limit, offset);
+ const [rows] = await db.execute(dataQuery, params);
+
+ return {
+ data: rows,
+ pagination: {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ total,
+ pages: Math.ceil(total / limit)
+ }
+ };
+ }
+
+ /**
+ * 获取所有支付订单列表(管理员)
+ * @param {Object} options - 查询选项
+ * @returns {Object} 分页结果
+ */
+ static async getAllPayments(options = {}) {
+ const {
+ page = 1,
+ limit = 10,
+ status,
+ payment_method,
+ user_id,
+ start_date,
+ end_date,
+ keyword
+ } = options;
+
+ const offset = (page - 1) * limit;
+ let whereConditions = ['p.deleted_at IS NULL'];
+ let params = [];
+
+ // 添加筛选条件
+ if (status) {
+ whereConditions.push('p.status = ?');
+ params.push(status);
+ }
+
+ if (payment_method) {
+ whereConditions.push('p.payment_method = ?');
+ params.push(payment_method);
+ }
+
+ if (user_id) {
+ whereConditions.push('p.user_id = ?');
+ params.push(user_id);
+ }
+
+ if (start_date) {
+ whereConditions.push('DATE(p.created_at) >= ?');
+ params.push(start_date);
+ }
+
+ if (end_date) {
+ whereConditions.push('DATE(p.created_at) <= ?');
+ params.push(end_date);
+ }
+
+ if (keyword) {
+ whereConditions.push('(p.payment_no LIKE ? OR o.order_no LIKE ? OR u.username LIKE ?)');
+ const keywordPattern = `%${keyword}%`;
+ params.push(keywordPattern, keywordPattern, keywordPattern);
+ }
+
+ const whereClause = whereConditions.join(' AND ');
+
+ // 查询总数
+ const countQuery = `
+ SELECT COUNT(*) as total
+ FROM payments p
+ LEFT JOIN orders o ON p.order_id = o.id
+ LEFT JOIN users u ON p.user_id = u.id
+ WHERE ${whereClause}
+ `;
+ const [countResult] = await db.execute(countQuery, params);
+ const total = countResult[0].total;
+
+ // 查询数据
+ const dataQuery = `
+ SELECT p.*, o.order_no, o.title as order_title,
+ u.username, u.phone
+ FROM payments p
+ LEFT JOIN orders o ON p.order_id = o.id
+ LEFT JOIN users u ON p.user_id = u.id
+ WHERE ${whereClause}
+ ORDER BY p.created_at DESC
+ LIMIT ? OFFSET ?
+ `;
+ params.push(limit, offset);
+ const [rows] = await db.execute(dataQuery, params);
+
+ return {
+ data: rows,
+ pagination: {
+ page: parseInt(page),
+ limit: parseInt(limit),
+ total,
+ pages: Math.ceil(total / limit)
+ }
+ };
+ }
+
+ /**
+ * 创建退款记录
+ * @param {Object} refundData - 退款数据
+ * @returns {Object} 创建的退款记录
+ */
+ static async createRefund(refundData) {
+ const {
+ refund_no,
+ payment_id,
+ user_id,
+ refund_amount,
+ refund_reason
+ } = refundData;
+
+ const query = `
+ INSERT INTO refunds (
+ refund_no, payment_id, user_id, refund_amount,
+ refund_reason, status, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, 'pending', NOW(), NOW())
+ `;
+
+ const [result] = await db.execute(query, [
+ refund_no, payment_id, user_id, refund_amount, refund_reason
+ ]);
+
+ return this.findRefundById(result.insertId);
+ }
+
+ /**
+ * 根据ID查找退款记录
+ * @param {number} id - 退款ID
+ * @returns {Object|null} 退款记录
+ */
+ static async findRefundById(id) {
+ const query = `
+ SELECT r.*, p.payment_no, p.amount as payment_amount,
+ u.username, u.phone,
+ admin.username as processed_by_name
+ FROM refunds r
+ LEFT JOIN payments p ON r.payment_id = p.id
+ LEFT JOIN users u ON r.user_id = u.id
+ LEFT JOIN users admin ON r.processed_by = admin.id
+ WHERE r.id = ? AND r.deleted_at IS NULL
+ `;
+
+ const [rows] = await db.execute(query, [id]);
+ return rows[0] || null;
+ }
+
+ /**
+ * 更新退款状态
+ * @param {number} id - 退款ID
+ * @param {Object} updateData - 更新数据
+ * @returns {Object} 更新后的退款记录
+ */
+ static async updateRefundStatus(id, updateData) {
+ const {
+ status,
+ processed_by,
+ process_remark,
+ refund_transaction_id,
+ refunded_at
+ } = updateData;
+
+ const query = `
+ UPDATE refunds
+ SET status = ?, processed_by = ?, process_remark = ?,
+ refund_transaction_id = ?, refunded_at = ?,
+ processed_at = NOW(), updated_at = NOW()
+ WHERE id = ? AND deleted_at IS NULL
+ `;
+
+ await db.execute(query, [
+ status, processed_by, process_remark,
+ refund_transaction_id, refunded_at, id
+ ]);
+
+ return this.findRefundById(id);
+ }
+
+ /**
+ * 获取支付统计信息
+ * @param {Object} filters - 筛选条件
+ * @returns {Object} 统计信息
+ */
+ static async getPaymentStatistics(filters = {}) {
+ const {
+ start_date,
+ end_date,
+ payment_method
+ } = filters;
+
+ let whereConditions = ['deleted_at IS NULL'];
+ let params = [];
+
+ if (start_date) {
+ whereConditions.push('DATE(created_at) >= ?');
+ params.push(start_date);
+ }
+
+ if (end_date) {
+ whereConditions.push('DATE(created_at) <= ?');
+ params.push(end_date);
+ }
+
+ if (payment_method) {
+ whereConditions.push('payment_method = ?');
+ params.push(payment_method);
+ }
+
+ const whereClause = whereConditions.join(' AND ');
+
+ // 总体统计
+ const totalQuery = `
+ SELECT
+ COUNT(*) as total_count,
+ COALESCE(SUM(amount), 0) as total_amount,
+ COUNT(CASE WHEN status = 'paid' THEN 1 END) as success_count,
+ COALESCE(SUM(CASE WHEN status = 'paid' THEN paid_amount END), 0) as success_amount
+ FROM payments
+ WHERE ${whereClause}
+ `;
+ const [totalResult] = await db.execute(totalQuery, params);
+
+ // 退款统计
+ const refundQuery = `
+ SELECT
+ COUNT(*) as refund_count,
+ COALESCE(SUM(refund_amount), 0) as refund_amount
+ FROM refunds r
+ JOIN payments p ON r.payment_id = p.id
+ WHERE r.status = 'completed' AND r.deleted_at IS NULL
+ ${start_date ? 'AND DATE(r.created_at) >= ?' : ''}
+ ${end_date ? 'AND DATE(r.created_at) <= ?' : ''}
+ ${payment_method ? 'AND p.payment_method = ?' : ''}
+ `;
+ let refundParams = [];
+ if (start_date) refundParams.push(start_date);
+ if (end_date) refundParams.push(end_date);
+ if (payment_method) refundParams.push(payment_method);
+
+ const [refundResult] = await db.execute(refundQuery, refundParams);
+
+ // 按支付方式统计
+ const methodQuery = `
+ SELECT
+ payment_method,
+ COUNT(*) as count,
+ COALESCE(SUM(CASE WHEN status = 'paid' THEN paid_amount END), 0) as amount
+ FROM payments
+ WHERE ${whereClause}
+ GROUP BY payment_method
+ `;
+ const [methodResult] = await db.execute(methodQuery, params);
+
+ return {
+ total_count: totalResult[0].total_count,
+ total_amount: parseFloat(totalResult[0].total_amount),
+ success_count: totalResult[0].success_count,
+ success_amount: parseFloat(totalResult[0].success_amount),
+ refund_count: refundResult[0].refund_count,
+ refund_amount: parseFloat(refundResult[0].refund_amount),
+ method_stats: methodResult.map(row => ({
+ payment_method: row.payment_method,
+ count: row.count,
+ amount: parseFloat(row.amount)
+ }))
+ };
+ }
+
+ /**
+ * 检查支付订单是否存在
+ * @param {number} id - 支付订单ID
+ * @returns {boolean} 是否存在
+ */
+ static async exists(id) {
+ const query = 'SELECT 1 FROM payments WHERE id = ? AND deleted_at IS NULL';
+ const [rows] = await db.execute(query, [id]);
+ return rows.length > 0;
+ }
+
+ /**
+ * 软删除支付订单
+ * @param {number} id - 支付订单ID
+ * @returns {boolean} 删除结果
+ */
+ static async softDelete(id) {
+ const query = `
+ UPDATE payments
+ SET deleted_at = NOW(), updated_at = NOW()
+ WHERE id = ? AND deleted_at IS NULL
+ `;
+
+ const [result] = await db.execute(query, [id]);
+ return result.affectedRows > 0;
+ }
+
+ /**
+ * 数据清理 - 删除过期的待支付订单
+ * @param {number} hours - 过期小时数,默认24小时
+ * @returns {number} 清理的记录数
+ */
+ static async cleanExpiredPayments(hours = 24) {
+ const query = `
+ UPDATE payments
+ SET status = 'cancelled', updated_at = NOW()
+ WHERE status = 'pending'
+ AND created_at < DATE_SUB(NOW(), INTERVAL ? HOUR)
+ AND deleted_at IS NULL
+ `;
+
+ const [result] = await db.execute(query, [hours]);
+ return result.affectedRows;
+ }
+}
+
+module.exports = Payment;
\ No newline at end of file
diff --git a/backend/src/models/TravelRegistration.js b/backend/src/models/TravelRegistration.js
new file mode 100644
index 0000000..f941fb4
--- /dev/null
+++ b/backend/src/models/TravelRegistration.js
@@ -0,0 +1,319 @@
+const db = require('../config/database');
+
+/**
+ * 旅行报名数据模型
+ * 处理旅行活动报名相关的数据库操作
+ */
+class TravelRegistration {
+ /**
+ * 创建报名记录
+ * @param {Object} registrationData - 报名数据
+ * @returns {Promise