重构认证系统和订单支付功能,新增邮箱验证、密码重置及支付流程
This commit is contained in:
86
README.md
86
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) - 如何参与项目开发
|
||||
|
||||
666
admin-system/src/components/AdvancedSearch.vue
Normal file
666
admin-system/src/components/AdvancedSearch.vue
Normal file
@@ -0,0 +1,666 @@
|
||||
<template>
|
||||
<div class="advanced-search">
|
||||
<a-card size="small">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<SearchOutlined />
|
||||
高级搜索
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button size="small" @click="handleReset">
|
||||
重置
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="toggleExpanded"
|
||||
:icon="expanded ? h(UpOutlined) : h(DownOutlined)"
|
||||
>
|
||||
{{ expanded ? '收起' : '展开' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-form
|
||||
:model="searchForm"
|
||||
layout="inline"
|
||||
@finish="handleSearch"
|
||||
class="search-form"
|
||||
>
|
||||
<!-- 基础搜索行 -->
|
||||
<div class="search-row basic-row">
|
||||
<a-form-item label="关键词" name="keyword">
|
||||
<a-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="请输入关键词"
|
||||
style="width: 200px"
|
||||
allow-clear
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select
|
||||
v-model:value="searchForm.status"
|
||||
placeholder="请选择状态"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
<a-tag :color="status.color" size="small">{{ status.label }}</a-tag>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="时间范围" name="dateRange">
|
||||
<a-range-picker
|
||||
v-model:value="searchForm.dateRange"
|
||||
style="width: 240px"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="searching">
|
||||
<SearchOutlined />
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 高级搜索行 -->
|
||||
<div v-show="expanded" class="search-row advanced-row">
|
||||
<!-- 用户相关字段 -->
|
||||
<template v-if="searchType === 'user'">
|
||||
<a-form-item label="用户类型" name="userType">
|
||||
<a-select
|
||||
v-model:value="searchForm.userType"
|
||||
placeholder="请选择用户类型"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="normal">普通用户</a-select-option>
|
||||
<a-select-option value="vip">VIP用户</a-select-option>
|
||||
<a-select-option value="admin">管理员</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="注册来源" name="registerSource">
|
||||
<a-select
|
||||
v-model:value="searchForm.registerSource"
|
||||
placeholder="请选择注册来源"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="web">网页端</a-select-option>
|
||||
<a-select-option value="wechat">微信小程序</a-select-option>
|
||||
<a-select-option value="app">移动应用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="年龄范围" name="ageRange">
|
||||
<a-slider
|
||||
v-model:value="searchForm.ageRange"
|
||||
range
|
||||
:min="0"
|
||||
:max="100"
|
||||
style="width: 200px"
|
||||
:tooltip-formatter="(value: number) => `${value}岁`"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="地区" name="region">
|
||||
<a-cascader
|
||||
v-model:value="searchForm.region"
|
||||
:options="regionOptions"
|
||||
placeholder="请选择地区"
|
||||
style="width: 200px"
|
||||
change-on-select
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 动物相关字段 -->
|
||||
<template v-if="searchType === 'animal'">
|
||||
<a-form-item label="动物类型" name="animalType">
|
||||
<a-select
|
||||
v-model:value="searchForm.animalType"
|
||||
placeholder="请选择动物类型"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="dog">狗</a-select-option>
|
||||
<a-select-option value="cat">猫</a-select-option>
|
||||
<a-select-option value="bird">鸟</a-select-option>
|
||||
<a-select-option value="other">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="品种" name="breed">
|
||||
<a-input
|
||||
v-model:value="searchForm.breed"
|
||||
placeholder="请输入品种"
|
||||
style="width: 150px"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="年龄范围" name="animalAgeRange">
|
||||
<a-input-group compact style="width: 200px">
|
||||
<a-input-number
|
||||
v-model:value="searchForm.minAge"
|
||||
placeholder="最小年龄"
|
||||
:min="0"
|
||||
:max="30"
|
||||
style="width: 50%"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model:value="searchForm.maxAge"
|
||||
placeholder="最大年龄"
|
||||
:min="0"
|
||||
:max="30"
|
||||
style="width: 50%"
|
||||
/>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="性别" name="gender">
|
||||
<a-radio-group v-model:value="searchForm.gender">
|
||||
<a-radio value="male">雄性</a-radio>
|
||||
<a-radio value="female">雌性</a-radio>
|
||||
<a-radio value="unknown">未知</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="健康状态" name="healthStatus">
|
||||
<a-select
|
||||
v-model:value="searchForm.healthStatus"
|
||||
placeholder="请选择健康状态"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="healthy">健康</a-select-option>
|
||||
<a-select-option value="sick">生病</a-select-option>
|
||||
<a-select-option value="injured">受伤</a-select-option>
|
||||
<a-select-option value="recovering">康复中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 订单相关字段 -->
|
||||
<template v-if="searchType === 'order'">
|
||||
<a-form-item label="订单类型" name="orderType">
|
||||
<a-select
|
||||
v-model:value="searchForm.orderType"
|
||||
placeholder="请选择订单类型"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="adoption">认领</a-select-option>
|
||||
<a-select-option value="donation">捐赠</a-select-option>
|
||||
<a-select-option value="service">服务</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="金额范围" name="amountRange">
|
||||
<a-input-group compact style="width: 200px">
|
||||
<a-input-number
|
||||
v-model:value="searchForm.minAmount"
|
||||
placeholder="最小金额"
|
||||
:min="0"
|
||||
style="width: 50%"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model:value="searchForm.maxAmount"
|
||||
placeholder="最大金额"
|
||||
:min="0"
|
||||
style="width: 50%"
|
||||
/>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="支付方式" name="paymentMethod">
|
||||
<a-select
|
||||
v-model:value="searchForm.paymentMethod"
|
||||
placeholder="请选择支付方式"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="wechat">微信支付</a-select-option>
|
||||
<a-select-option value="alipay">支付宝</a-select-option>
|
||||
<a-select-option value="bank">银行卡</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 通用高级字段 -->
|
||||
<a-form-item label="创建人" name="creator">
|
||||
<a-input
|
||||
v-model:value="searchForm.creator"
|
||||
placeholder="请输入创建人"
|
||||
style="width: 150px"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="排序方式" name="sortBy">
|
||||
<a-select
|
||||
v-model:value="searchForm.sortBy"
|
||||
placeholder="请选择排序方式"
|
||||
style="width: 150px"
|
||||
>
|
||||
<a-select-option value="created_at_desc">创建时间降序</a-select-option>
|
||||
<a-select-option value="created_at_asc">创建时间升序</a-select-option>
|
||||
<a-select-option value="updated_at_desc">更新时间降序</a-select-option>
|
||||
<a-select-option value="updated_at_asc">更新时间升序</a-select-option>
|
||||
<a-select-option value="name_asc">名称升序</a-select-option>
|
||||
<a-select-option value="name_desc">名称降序</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 快速筛选标签 -->
|
||||
<div class="quick-filters" v-if="quickFilters.length > 0">
|
||||
<a-divider orientation="left" orientation-margin="0">快速筛选</a-divider>
|
||||
<a-space wrap>
|
||||
<a-tag
|
||||
v-for="filter in quickFilters"
|
||||
:key="filter.key"
|
||||
:color="filter.active ? 'blue' : 'default'"
|
||||
style="cursor: pointer"
|
||||
@click="handleQuickFilter(filter)"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<div class="search-history" v-if="searchHistory.length > 0 && expanded">
|
||||
<a-divider orientation="left" orientation-margin="0">搜索历史</a-divider>
|
||||
<a-space wrap>
|
||||
<a-tag
|
||||
v-for="(history, index) in searchHistory.slice(0, 5)"
|
||||
:key="index"
|
||||
closable
|
||||
@close="removeSearchHistory(index)"
|
||||
@click="applySearchHistory(history)"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
{{ history.keyword || '无关键词' }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, h } from 'vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
DownOutlined,
|
||||
UpOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
interface SearchForm {
|
||||
keyword?: string
|
||||
status?: string
|
||||
dateRange?: [string, string]
|
||||
userType?: string
|
||||
registerSource?: string
|
||||
ageRange?: [number, number]
|
||||
region?: string[]
|
||||
animalType?: string
|
||||
breed?: string
|
||||
minAge?: number
|
||||
maxAge?: number
|
||||
gender?: string
|
||||
healthStatus?: string
|
||||
orderType?: string
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
paymentMethod?: string
|
||||
creator?: string
|
||||
sortBy?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface StatusOption {
|
||||
value: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface QuickFilter {
|
||||
key: string
|
||||
label: string
|
||||
active: boolean
|
||||
params: Partial<SearchForm>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
searchType: 'user' | 'animal' | 'order' | 'travel'
|
||||
statusOptions?: StatusOption[]
|
||||
defaultValues?: Partial<SearchForm>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
statusOptions: () => [
|
||||
{ value: 'active', label: '激活', color: 'green' },
|
||||
{ value: 'inactive', label: '禁用', color: 'red' },
|
||||
{ value: 'pending', label: '待审核', color: 'orange' }
|
||||
]
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'search': [params: SearchForm]
|
||||
'reset': []
|
||||
}>()
|
||||
|
||||
// 响应式数据
|
||||
const expanded = ref(false)
|
||||
const searching = ref(false)
|
||||
|
||||
const searchForm = reactive<SearchForm>({
|
||||
keyword: '',
|
||||
status: undefined,
|
||||
dateRange: undefined,
|
||||
sortBy: 'created_at_desc',
|
||||
ageRange: [0, 100],
|
||||
...props.defaultValues
|
||||
})
|
||||
|
||||
// 地区选项(示例数据)
|
||||
const regionOptions = ref([
|
||||
{
|
||||
value: 'beijing',
|
||||
label: '北京市',
|
||||
children: [
|
||||
{ value: 'chaoyang', label: '朝阳区' },
|
||||
{ value: 'haidian', label: '海淀区' },
|
||||
{ value: 'dongcheng', label: '东城区' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'shanghai',
|
||||
label: '上海市',
|
||||
children: [
|
||||
{ value: 'huangpu', label: '黄浦区' },
|
||||
{ value: 'xuhui', label: '徐汇区' },
|
||||
{ value: 'changning', label: '长宁区' }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
// 快速筛选
|
||||
const quickFilters = ref<QuickFilter[]>([])
|
||||
|
||||
// 搜索历史
|
||||
const searchHistory = ref<SearchForm[]>([])
|
||||
|
||||
// 初始化快速筛选
|
||||
const initQuickFilters = () => {
|
||||
const baseFilters: QuickFilter[] = [
|
||||
{
|
||||
key: 'today',
|
||||
label: '今日新增',
|
||||
active: false,
|
||||
params: {
|
||||
dateRange: [
|
||||
new Date().toISOString().split('T')[0],
|
||||
new Date().toISOString().split('T')[0]
|
||||
] as [string, string]
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'week',
|
||||
label: '本周新增',
|
||||
active: false,
|
||||
params: {
|
||||
dateRange: [
|
||||
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
new Date().toISOString().split('T')[0]
|
||||
] as [string, string]
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
label: '仅显示激活',
|
||||
active: false,
|
||||
params: { status: 'active' }
|
||||
}
|
||||
]
|
||||
|
||||
// 根据搜索类型添加特定筛选
|
||||
switch (props.searchType) {
|
||||
case 'user':
|
||||
baseFilters.push(
|
||||
{
|
||||
key: 'vip',
|
||||
label: 'VIP用户',
|
||||
active: false,
|
||||
params: { userType: 'vip' }
|
||||
},
|
||||
{
|
||||
key: 'wechat',
|
||||
label: '微信用户',
|
||||
active: false,
|
||||
params: { registerSource: 'wechat' }
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
case 'animal':
|
||||
baseFilters.push(
|
||||
{
|
||||
key: 'healthy',
|
||||
label: '健康动物',
|
||||
active: false,
|
||||
params: { healthStatus: 'healthy' }
|
||||
},
|
||||
{
|
||||
key: 'young',
|
||||
label: '幼年动物',
|
||||
active: false,
|
||||
params: { minAge: 0, maxAge: 2 }
|
||||
}
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
quickFilters.value = baseFilters
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换展开/收起
|
||||
*/
|
||||
const toggleExpanded = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行搜索
|
||||
*/
|
||||
const handleSearch = () => {
|
||||
searching.value = true
|
||||
|
||||
// 清理空值
|
||||
const cleanParams = Object.keys(searchForm).reduce((acc, key) => {
|
||||
const value = searchForm[key]
|
||||
if (value !== undefined && value !== null && value !== '' &&
|
||||
!(Array.isArray(value) && value.length === 0)) {
|
||||
acc[key] = value
|
||||
}
|
||||
return acc
|
||||
}, {} as SearchForm)
|
||||
|
||||
// 保存搜索历史
|
||||
saveSearchHistory(cleanParams)
|
||||
|
||||
emit('search', cleanParams)
|
||||
|
||||
setTimeout(() => {
|
||||
searching.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置搜索
|
||||
*/
|
||||
const handleReset = () => {
|
||||
Object.keys(searchForm).forEach(key => {
|
||||
if (key === 'sortBy') {
|
||||
searchForm[key] = 'created_at_desc'
|
||||
} else if (key === 'ageRange') {
|
||||
searchForm[key] = [0, 100]
|
||||
} else {
|
||||
searchForm[key] = undefined
|
||||
}
|
||||
})
|
||||
|
||||
// 重置快速筛选
|
||||
quickFilters.value.forEach(filter => {
|
||||
filter.active = false
|
||||
})
|
||||
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速筛选
|
||||
*/
|
||||
const handleQuickFilter = (filter: QuickFilter) => {
|
||||
filter.active = !filter.active
|
||||
|
||||
if (filter.active) {
|
||||
// 应用筛选参数
|
||||
Object.assign(searchForm, filter.params)
|
||||
} else {
|
||||
// 移除筛选参数
|
||||
Object.keys(filter.params).forEach(key => {
|
||||
searchForm[key] = undefined
|
||||
})
|
||||
}
|
||||
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存搜索历史
|
||||
*/
|
||||
const saveSearchHistory = (params: SearchForm) => {
|
||||
// 避免重复
|
||||
const exists = searchHistory.value.some(history =>
|
||||
JSON.stringify(history) === JSON.stringify(params)
|
||||
)
|
||||
|
||||
if (!exists) {
|
||||
searchHistory.value.unshift(params)
|
||||
// 最多保存10条历史
|
||||
if (searchHistory.value.length > 10) {
|
||||
searchHistory.value = searchHistory.value.slice(0, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用搜索历史
|
||||
*/
|
||||
const applySearchHistory = (history: SearchForm) => {
|
||||
Object.assign(searchForm, history)
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除搜索历史
|
||||
*/
|
||||
const removeSearchHistory = (index: number) => {
|
||||
searchHistory.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 初始化
|
||||
initQuickFilters()
|
||||
|
||||
// 监听搜索类型变化
|
||||
watch(() => props.searchType, () => {
|
||||
initQuickFilters()
|
||||
handleReset()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.advanced-search {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.basic-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.advanced-row {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.quick-filters,
|
||||
.search-history {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.quick-filters :deep(.ant-divider),
|
||||
.search-history :deep(.ant-divider) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label) {
|
||||
width: auto;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-row {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
455
admin-system/src/components/BatchOperations.vue
Normal file
455
admin-system/src/components/BatchOperations.vue
Normal file
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<div class="batch-operations">
|
||||
<a-card title="批量操作" size="small">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="handleSelectAll"
|
||||
:disabled="!dataSource.length"
|
||||
>
|
||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="handleClearSelection"
|
||||
:disabled="!selectedItems.length"
|
||||
>
|
||||
清空选择
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<div class="batch-info" v-if="selectedItems.length > 0">
|
||||
<a-alert
|
||||
:message="`已选择 ${selectedItems.length} 项`"
|
||||
type="info"
|
||||
show-icon
|
||||
closable
|
||||
@close="handleClearSelection"
|
||||
>
|
||||
<template #action>
|
||||
<a-space>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<template #overlay>
|
||||
<a-menu @click="handleBatchAction">
|
||||
<a-menu-item
|
||||
v-for="action in availableActions"
|
||||
:key="action.key"
|
||||
:disabled="action.disabled"
|
||||
>
|
||||
<component :is="action.icon" v-if="action.icon" />
|
||||
{{ action.label }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button type="primary" size="small">
|
||||
批量操作
|
||||
<DownOutlined />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 批量状态更新模态框 -->
|
||||
<a-modal
|
||||
v-model:open="statusModalVisible"
|
||||
title="批量状态更新"
|
||||
@ok="handleStatusUpdate"
|
||||
:confirm-loading="statusUpdateLoading"
|
||||
>
|
||||
<a-form :model="statusForm" layout="vertical">
|
||||
<a-form-item label="新状态" required>
|
||||
<a-select v-model:value="statusForm.status" placeholder="请选择状态">
|
||||
<a-select-option
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
<a-tag :color="status.color">{{ status.label }}</a-tag>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="操作原因">
|
||||
<a-textarea
|
||||
v-model:value="statusForm.reason"
|
||||
placeholder="请输入操作原因(可选)"
|
||||
:rows="3"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-alert
|
||||
:message="`将对 ${selectedItems.length} 个项目执行状态更新操作`"
|
||||
type="warning"
|
||||
show-icon
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 批量删除确认模态框 -->
|
||||
<a-modal
|
||||
v-model:open="deleteModalVisible"
|
||||
title="批量删除确认"
|
||||
@ok="handleBatchDelete"
|
||||
:confirm-loading="deleteLoading"
|
||||
ok-text="确认删除"
|
||||
ok-type="danger"
|
||||
>
|
||||
<a-alert
|
||||
message="危险操作"
|
||||
:description="`您即将删除 ${selectedItems.length} 个项目,此操作不可撤销!`"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<div style="margin-top: 16px;">
|
||||
<a-checkbox v-model:checked="deleteConfirm">
|
||||
我确认要执行此删除操作
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 批量导出模态框 -->
|
||||
<a-modal
|
||||
v-model:open="exportModalVisible"
|
||||
title="批量导出"
|
||||
@ok="handleBatchExport"
|
||||
:confirm-loading="exportLoading"
|
||||
>
|
||||
<a-form :model="exportForm" layout="vertical">
|
||||
<a-form-item label="导出格式" required>
|
||||
<a-radio-group v-model:value="exportForm.format">
|
||||
<a-radio value="csv">CSV格式</a-radio>
|
||||
<a-radio value="excel">Excel格式</a-radio>
|
||||
<a-radio value="json">JSON格式</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="导出字段">
|
||||
<a-checkbox-group v-model:value="exportForm.fields">
|
||||
<a-row>
|
||||
<a-col :span="8" v-for="field in exportFields" :key="field.key">
|
||||
<a-checkbox :value="field.key">{{ field.label }}</a-checkbox>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-alert
|
||||
:message="`将导出 ${selectedItems.length} 个项目的数据`"
|
||||
type="info"
|
||||
show-icon
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
DownOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
SendOutlined,
|
||||
LockOutlined,
|
||||
UnlockOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
|
||||
interface BatchItem {
|
||||
id: number | string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface BatchAction {
|
||||
key: string
|
||||
label: string
|
||||
icon?: any
|
||||
disabled?: boolean
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface StatusOption {
|
||||
value: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface ExportField {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dataSource: BatchItem[]
|
||||
selectedItems: BatchItem[]
|
||||
operationType: 'user' | 'animal' | 'order' | 'travel'
|
||||
statusOptions?: StatusOption[]
|
||||
exportFields?: ExportField[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
statusOptions: () => [
|
||||
{ value: 'active', label: '激活', color: 'green' },
|
||||
{ value: 'inactive', label: '禁用', color: 'red' },
|
||||
{ value: 'pending', label: '待审核', color: 'orange' }
|
||||
],
|
||||
exportFields: () => [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'created_at', label: '创建时间' }
|
||||
]
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'selection-change': [items: BatchItem[]]
|
||||
'batch-action': [action: string, items: BatchItem[], params?: any]
|
||||
}>()
|
||||
|
||||
// 模态框状态
|
||||
const statusModalVisible = ref(false)
|
||||
const deleteModalVisible = ref(false)
|
||||
const exportModalVisible = ref(false)
|
||||
|
||||
// 加载状态
|
||||
const statusUpdateLoading = ref(false)
|
||||
const deleteLoading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const statusForm = ref({
|
||||
status: '',
|
||||
reason: ''
|
||||
})
|
||||
|
||||
const exportForm = ref({
|
||||
format: 'csv',
|
||||
fields: props.exportFields.map(f => f.key)
|
||||
})
|
||||
|
||||
const deleteConfirm = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const isAllSelected = computed(() => {
|
||||
return props.dataSource.length > 0 && props.selectedItems.length === props.dataSource.length
|
||||
})
|
||||
|
||||
const availableActions = computed((): BatchAction[] => {
|
||||
const baseActions: BatchAction[] = [
|
||||
{
|
||||
key: 'update-status',
|
||||
label: '更新状态',
|
||||
icon: EditOutlined
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
label: '导出数据',
|
||||
icon: DownloadOutlined
|
||||
}
|
||||
]
|
||||
|
||||
// 根据操作类型添加特定操作
|
||||
switch (props.operationType) {
|
||||
case 'user':
|
||||
baseActions.push(
|
||||
{
|
||||
key: 'send-message',
|
||||
label: '发送消息',
|
||||
icon: SendOutlined
|
||||
},
|
||||
{
|
||||
key: 'lock',
|
||||
label: '锁定账户',
|
||||
icon: LockOutlined
|
||||
},
|
||||
{
|
||||
key: 'unlock',
|
||||
label: '解锁账户',
|
||||
icon: UnlockOutlined
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
case 'animal':
|
||||
baseActions.push(
|
||||
{
|
||||
key: 'batch-approve',
|
||||
label: '批量审核',
|
||||
icon: EditOutlined
|
||||
}
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// 危险操作
|
||||
baseActions.push({
|
||||
key: 'delete',
|
||||
label: '批量删除',
|
||||
icon: DeleteOutlined,
|
||||
danger: true
|
||||
})
|
||||
|
||||
return baseActions
|
||||
})
|
||||
|
||||
/**
|
||||
* 全选/取消全选
|
||||
*/
|
||||
const handleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
emit('selection-change', [])
|
||||
} else {
|
||||
emit('selection-change', [...props.dataSource])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空选择
|
||||
*/
|
||||
const handleClearSelection = () => {
|
||||
emit('selection-change', [])
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量操作
|
||||
*/
|
||||
const handleBatchAction = ({ key }: { key: string }) => {
|
||||
if (!props.selectedItems.length) {
|
||||
message.warning('请先选择要操作的项目')
|
||||
return
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'update-status':
|
||||
statusModalVisible.value = true
|
||||
statusForm.value = { status: '', reason: '' }
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
deleteModalVisible.value = true
|
||||
deleteConfirm.value = false
|
||||
break
|
||||
|
||||
case 'export':
|
||||
exportModalVisible.value = true
|
||||
exportForm.value.fields = props.exportFields.map(f => f.key)
|
||||
break
|
||||
|
||||
default:
|
||||
// 直接执行其他操作
|
||||
emit('batch-action', key, props.selectedItems)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理状态更新
|
||||
*/
|
||||
const handleStatusUpdate = async () => {
|
||||
if (!statusForm.value.status) {
|
||||
message.error('请选择新状态')
|
||||
return
|
||||
}
|
||||
|
||||
statusUpdateLoading.value = true
|
||||
|
||||
try {
|
||||
emit('batch-action', 'update-status', props.selectedItems, {
|
||||
status: statusForm.value.status,
|
||||
reason: statusForm.value.reason
|
||||
})
|
||||
|
||||
statusModalVisible.value = false
|
||||
message.success(`成功更新 ${props.selectedItems.length} 个项目的状态`)
|
||||
} catch (error) {
|
||||
message.error('批量状态更新失败')
|
||||
} finally {
|
||||
statusUpdateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量删除
|
||||
*/
|
||||
const handleBatchDelete = async () => {
|
||||
if (!deleteConfirm.value) {
|
||||
message.error('请确认删除操作')
|
||||
return
|
||||
}
|
||||
|
||||
deleteLoading.value = true
|
||||
|
||||
try {
|
||||
emit('batch-action', 'delete', props.selectedItems)
|
||||
|
||||
deleteModalVisible.value = false
|
||||
message.success(`成功删除 ${props.selectedItems.length} 个项目`)
|
||||
} catch (error) {
|
||||
message.error('批量删除失败')
|
||||
} finally {
|
||||
deleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量导出
|
||||
*/
|
||||
const handleBatchExport = async () => {
|
||||
if (!exportForm.value.fields.length) {
|
||||
message.error('请选择要导出的字段')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
|
||||
try {
|
||||
emit('batch-action', 'export', props.selectedItems, {
|
||||
format: exportForm.value.format,
|
||||
fields: exportForm.value.fields
|
||||
})
|
||||
|
||||
exportModalVisible.value = false
|
||||
message.success(`开始导出 ${props.selectedItems.length} 个项目的数据`)
|
||||
} catch (error) {
|
||||
message.error('批量导出失败')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听选择变化
|
||||
watch(() => props.selectedItems, (newItems) => {
|
||||
// 可以在这里添加选择变化的逻辑
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.batch-operations {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.batch-info {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.batch-info :deep(.ant-alert) {
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.batch-info :deep(.ant-alert-action) {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
342
admin-system/src/components/charts/DataStatisticsChart.vue
Normal file
342
admin-system/src/components/charts/DataStatisticsChart.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<div class="data-statistics-chart">
|
||||
<a-card :title="title" :loading="loading">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model:value="selectedPeriod"
|
||||
style="width: 120px"
|
||||
@change="handlePeriodChange"
|
||||
>
|
||||
<a-select-option value="7d">近7天</a-select-option>
|
||||
<a-select-option value="30d">近30天</a-select-option>
|
||||
<a-select-option value="90d">近90天</a-select-option>
|
||||
<a-select-option value="365d">近一年</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="handleRefresh" :loading="loading">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<div ref="chartContainer" :style="{ height: chartHeight + 'px' }"></div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
interface ChartData {
|
||||
date: string
|
||||
value: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
chartType: 'line' | 'bar' | 'pie' | 'area'
|
||||
dataSource: string // API接口地址
|
||||
chartHeight?: number
|
||||
xAxisKey?: string
|
||||
yAxisKey?: string
|
||||
seriesConfig?: any[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
chartHeight: 300,
|
||||
xAxisKey: 'date',
|
||||
yAxisKey: 'value',
|
||||
seriesConfig: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
dataLoaded: [data: ChartData[]]
|
||||
error: [error: Error]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const selectedPeriod = ref('30d')
|
||||
const chartContainer = ref<HTMLDivElement>()
|
||||
let chartInstance: echarts.ECharts | null = null
|
||||
const chartData = ref<ChartData[]>([])
|
||||
|
||||
/**
|
||||
* 初始化图表
|
||||
*/
|
||||
const initChart = () => {
|
||||
if (!chartContainer.value) return
|
||||
|
||||
chartInstance = echarts.init(chartContainer.value)
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleResize)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新图表配置
|
||||
*/
|
||||
const updateChart = () => {
|
||||
if (!chartInstance || !chartData.value.length) return
|
||||
|
||||
const option = generateChartOption()
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成图表配置
|
||||
*/
|
||||
const generateChartOption = () => {
|
||||
const baseOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: props.seriesConfig.map(s => s.name) || ['数据']
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
title: '保存为图片'
|
||||
},
|
||||
dataZoom: {
|
||||
title: {
|
||||
zoom: '区域缩放',
|
||||
back: '区域缩放还原'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据图表类型生成不同配置
|
||||
switch (props.chartType) {
|
||||
case 'line':
|
||||
case 'area':
|
||||
return {
|
||||
...baseOption,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: chartData.value.map(item => item[props.xAxisKey])
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: props.seriesConfig.length > 0
|
||||
? props.seriesConfig.map(config => ({
|
||||
...config,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: props.chartType === 'area' ? {} : undefined,
|
||||
data: chartData.value.map(item => item[config.dataKey || props.yAxisKey])
|
||||
}))
|
||||
: [{
|
||||
name: '数据',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: props.chartType === 'area' ? {} : undefined,
|
||||
data: chartData.value.map(item => item[props.yAxisKey])
|
||||
}]
|
||||
}
|
||||
|
||||
case 'bar':
|
||||
return {
|
||||
...baseOption,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.value.map(item => item[props.xAxisKey])
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: props.seriesConfig.length > 0
|
||||
? props.seriesConfig.map(config => ({
|
||||
...config,
|
||||
type: 'bar',
|
||||
data: chartData.value.map(item => item[config.dataKey || props.yAxisKey])
|
||||
}))
|
||||
: [{
|
||||
name: '数据',
|
||||
type: 'bar',
|
||||
data: chartData.value.map(item => item[props.yAxisKey])
|
||||
}]
|
||||
}
|
||||
|
||||
case 'pie':
|
||||
return {
|
||||
...baseOption,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
series: [{
|
||||
name: props.title,
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: chartData.value.map(item => ({
|
||||
name: item[props.xAxisKey],
|
||||
value: item[props.yAxisKey]
|
||||
})),
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
default:
|
||||
return baseOption
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载图表数据
|
||||
*/
|
||||
const loadData = async () => {
|
||||
if (!props.dataSource) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 这里应该调用实际的API接口
|
||||
// const response = await fetch(`${props.dataSource}?period=${selectedPeriod.value}`)
|
||||
// const result = await response.json()
|
||||
|
||||
// 模拟数据加载
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 生成模拟数据
|
||||
const mockData = generateMockData()
|
||||
chartData.value = mockData
|
||||
|
||||
emit('dataLoaded', mockData)
|
||||
|
||||
// 更新图表
|
||||
nextTick(() => {
|
||||
updateChart()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载图表数据失败:', error)
|
||||
message.error('加载图表数据失败')
|
||||
emit('error', error as Error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成模拟数据
|
||||
*/
|
||||
const generateMockData = (): ChartData[] => {
|
||||
const days = selectedPeriod.value === '7d' ? 7 :
|
||||
selectedPeriod.value === '30d' ? 30 :
|
||||
selectedPeriod.value === '90d' ? 90 : 365
|
||||
|
||||
const data: ChartData[] = []
|
||||
const now = new Date()
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() - i)
|
||||
|
||||
data.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
value: Math.floor(Math.random() * 100) + 50,
|
||||
users: Math.floor(Math.random() * 50) + 20,
|
||||
orders: Math.floor(Math.random() * 30) + 10,
|
||||
revenue: Math.floor(Math.random() * 5000) + 1000
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理时间周期变化
|
||||
*/
|
||||
const handlePeriodChange = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
const handleRefresh = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理窗口大小变化
|
||||
*/
|
||||
const handleResize = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听数据变化
|
||||
*/
|
||||
watch(() => props.dataSource, () => {
|
||||
loadData()
|
||||
}, { immediate: false })
|
||||
|
||||
/**
|
||||
* 组件挂载
|
||||
*/
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
loadData()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 组件卸载
|
||||
*/
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refresh: handleRefresh,
|
||||
updateData: (data: ChartData[]) => {
|
||||
chartData.value = data
|
||||
updateChart()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-statistics-chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-statistics-chart :deep(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
219
admin-system/src/pages/animals/components/AnimalDetail.vue
Normal file
219
admin-system/src/pages/animals/components/AnimalDetail.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
title="动物详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div v-if="animal" class="animal-detail">
|
||||
<!-- 基本信息 -->
|
||||
<a-card title="基本信息" class="mb-4">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<div class="animal-avatar">
|
||||
<a-avatar
|
||||
:src="animal.avatar"
|
||||
:alt="animal.name"
|
||||
:size="120"
|
||||
shape="square"
|
||||
>
|
||||
{{ animal.name?.charAt(0) }}
|
||||
</a-avatar>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="18">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>动物ID:</label>
|
||||
<span>{{ animal.id }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>名称:</label>
|
||||
<span>{{ animal.name }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>类型:</label>
|
||||
<a-tag color="blue">{{ animal.type }}</a-tag>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>品种:</label>
|
||||
<span>{{ animal.breed }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>年龄:</label>
|
||||
<span>{{ animal.age }}岁</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>性别:</label>
|
||||
<span>{{ animal.gender }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>颜色:</label>
|
||||
<span>{{ animal.color }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>状态:</label>
|
||||
<a-tag :color="getStatusColor(animal.status)">
|
||||
{{ getStatusText(animal.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>健康状态:</label>
|
||||
<span>{{ animal.health_status }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 详细描述 -->
|
||||
<a-card title="详细描述" class="mb-4">
|
||||
<p>{{ animal.description || '暂无描述' }}</p>
|
||||
</a-card>
|
||||
|
||||
<!-- 位置信息 -->
|
||||
<a-card title="位置信息" class="mb-4">
|
||||
<div class="detail-item">
|
||||
<label>当前位置:</label>
|
||||
<span>{{ animal.location }}</span>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<a-card title="时间信息">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>创建时间:</label>
|
||||
<span>{{ formatDate(animal.createdAt) }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>更新时间:</label>
|
||||
<span>{{ formatDate(animal.updatedAt) }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Animal {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
breed: string
|
||||
age: number
|
||||
gender: string
|
||||
color: string
|
||||
avatar: string
|
||||
description: string
|
||||
status: 'available' | 'adopted' | 'unavailable'
|
||||
health_status: string
|
||||
location: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
animal: Animal | null
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
available: 'green',
|
||||
adopted: 'blue',
|
||||
unavailable: 'red'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
available: '可领养',
|
||||
adopted: '已领养',
|
||||
unavailable: '不可领养'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animal-detail {
|
||||
.animal-avatar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
margin-bottom: 12px;
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
362
admin-system/src/pages/animals/components/AnimalForm.vue
Normal file
362
admin-system/src/pages/animals/components/AnimalForm.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="isEditing ? '编辑动物' : '新增动物'"
|
||||
width="800px"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="动物名称" name="name">
|
||||
<a-input
|
||||
v-model:value="formData.name"
|
||||
placeholder="请输入动物名称"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="动物类型" name="type">
|
||||
<a-select
|
||||
v-model:value="formData.type"
|
||||
placeholder="请选择动物类型"
|
||||
>
|
||||
<a-select-option value="狗">狗</a-select-option>
|
||||
<a-select-option value="猫">猫</a-select-option>
|
||||
<a-select-option value="兔子">兔子</a-select-option>
|
||||
<a-select-option value="鸟类">鸟类</a-select-option>
|
||||
<a-select-option value="其他">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="品种" name="breed">
|
||||
<a-input
|
||||
v-model:value="formData.breed"
|
||||
placeholder="请输入品种"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="年龄" name="age">
|
||||
<a-input-number
|
||||
v-model:value="formData.age"
|
||||
placeholder="请输入年龄"
|
||||
:min="0"
|
||||
:max="30"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="性别" name="gender">
|
||||
<a-select
|
||||
v-model:value="formData.gender"
|
||||
placeholder="请选择性别"
|
||||
>
|
||||
<a-select-option value="雄性">雄性</a-select-option>
|
||||
<a-select-option value="雌性">雌性</a-select-option>
|
||||
<a-select-option value="未知">未知</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="颜色" name="color">
|
||||
<a-input
|
||||
v-model:value="formData.color"
|
||||
placeholder="请输入颜色"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select
|
||||
v-model:value="formData.status"
|
||||
placeholder="请选择状态"
|
||||
>
|
||||
<a-select-option value="available">可领养</a-select-option>
|
||||
<a-select-option value="adopted">已领养</a-select-option>
|
||||
<a-select-option value="unavailable">不可领养</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="健康状态" name="health_status">
|
||||
<a-select
|
||||
v-model:value="formData.health_status"
|
||||
placeholder="请选择健康状态"
|
||||
>
|
||||
<a-select-option value="健康">健康</a-select-option>
|
||||
<a-select-option value="轻微疾病">轻微疾病</a-select-option>
|
||||
<a-select-option value="需要治疗">需要治疗</a-select-option>
|
||||
<a-select-option value="康复中">康复中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="位置" name="location">
|
||||
<a-input
|
||||
v-model:value="formData.location"
|
||||
placeholder="请输入当前位置"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="头像" name="avatar">
|
||||
<a-upload
|
||||
v-model:file-list="fileList"
|
||||
name="avatar"
|
||||
list-type="picture-card"
|
||||
class="avatar-uploader"
|
||||
:show-upload-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
@change="handleChange"
|
||||
>
|
||||
<div v-if="formData.avatar">
|
||||
<img :src="formData.avatar" alt="avatar" style="width: 100%" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<PlusOutlined />
|
||||
<div style="margin-top: 8px">上传头像</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-textarea
|
||||
v-model:value="formData.description"
|
||||
placeholder="请输入动物描述"
|
||||
:rows="4"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { FormInstance, UploadChangeParam } from 'ant-design-vue'
|
||||
|
||||
interface Animal {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
breed: string
|
||||
age: number
|
||||
gender: string
|
||||
color: string
|
||||
avatar: string
|
||||
description: string
|
||||
status: 'available' | 'adopted' | 'unavailable'
|
||||
health_status: string
|
||||
location: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
animal: Animal | null
|
||||
visible: boolean
|
||||
isEditing: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'submit', data: any): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const fileList = ref([])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
type: '',
|
||||
breed: '',
|
||||
age: 0,
|
||||
gender: '',
|
||||
color: '',
|
||||
status: 'available' as 'available' | 'adopted' | 'unavailable',
|
||||
health_status: '健康',
|
||||
location: '',
|
||||
avatar: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules: Record<string, any[]> = {
|
||||
name: [
|
||||
{ required: true, message: '请输入动物名称', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: '名称长度为1-50个字符', trigger: 'blur' }
|
||||
],
|
||||
type: [
|
||||
{ required: true, message: '请选择动物类型', trigger: 'change' }
|
||||
],
|
||||
breed: [
|
||||
{ required: true, message: '请输入品种', trigger: 'blur' }
|
||||
],
|
||||
age: [
|
||||
{ required: true, message: '请输入年龄', trigger: 'blur' },
|
||||
{ type: 'number', min: 0, max: 30, message: '年龄必须在0-30之间', trigger: 'blur' }
|
||||
],
|
||||
gender: [
|
||||
{ required: true, message: '请选择性别', trigger: 'change' }
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||
],
|
||||
location: [
|
||||
{ required: true, message: '请输入位置', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 监听动物数据变化,初始化表单
|
||||
watch(() => props.animal, (animal) => {
|
||||
if (animal && props.isEditing) {
|
||||
formData.name = animal.name
|
||||
formData.type = animal.type
|
||||
formData.breed = animal.breed
|
||||
formData.age = animal.age
|
||||
formData.gender = animal.gender
|
||||
formData.color = animal.color
|
||||
formData.status = animal.status
|
||||
formData.health_status = animal.health_status
|
||||
formData.location = animal.location
|
||||
formData.avatar = animal.avatar
|
||||
formData.description = animal.description
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听弹窗显示状态,重置表单
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible && !props.isEditing) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
type: '',
|
||||
breed: '',
|
||||
age: 0,
|
||||
gender: '',
|
||||
color: '',
|
||||
status: 'available',
|
||||
health_status: '健康',
|
||||
location: '',
|
||||
avatar: '',
|
||||
description: ''
|
||||
})
|
||||
fileList.value = []
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 文件上传前验证
|
||||
const beforeUpload = (file: File) => {
|
||||
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
|
||||
if (!isJpgOrPng) {
|
||||
message.error('只能上传 JPG/PNG 格式的图片!')
|
||||
return false
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
if (!isLt2M) {
|
||||
message.error('图片大小不能超过 2MB!')
|
||||
return false
|
||||
}
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
// 文件上传变化处理
|
||||
const handleChange = (info: UploadChangeParam) => {
|
||||
if (info.file.originFileObj) {
|
||||
// 创建预览URL
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
formData.avatar = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(info.file.originFileObj)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
|
||||
const submitData = {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
breed: formData.breed,
|
||||
age: formData.age,
|
||||
gender: formData.gender,
|
||||
color: formData.color,
|
||||
status: formData.status,
|
||||
health_status: formData.health_status,
|
||||
location: formData.location,
|
||||
avatar: formData.avatar,
|
||||
description: formData.description
|
||||
}
|
||||
|
||||
emit('submit', submitData)
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar-uploader {
|
||||
:deep(.ant-upload) {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
:deep(.ant-upload-select-picture-card) {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
716
admin-system/src/pages/animals/index.vue
Normal file
716
admin-system/src/pages/animals/index.vue
Normal file
@@ -0,0 +1,716 @@
|
||||
<template>
|
||||
<div class="animals-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1>动物管理</h1>
|
||||
<p>管理平台上的所有动物信息</p>
|
||||
</div>
|
||||
|
||||
<!-- 数据统计 -->
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="总动物数"
|
||||
:value="statistics.totalAnimals"
|
||||
:value-style="{ color: '#3f8600' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<HeartOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="可领养"
|
||||
:value="statistics.availableAnimals"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<SmileOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="已领养"
|
||||
:value="statistics.claimedAnimals"
|
||||
:value-style="{ color: '#722ed1' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<HomeOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="今日新增"
|
||||
:value="statistics.newAnimalsToday"
|
||||
:value-style="{ color: '#cf1322' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 高级搜索 -->
|
||||
<AdvancedSearch
|
||||
search-type="animal"
|
||||
:status-options="statusOptions"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<BatchOperations
|
||||
:data-source="animals as any[]"
|
||||
:selected-items="selectedAnimals as any[]"
|
||||
operation-type="animal"
|
||||
:status-options="statusOptionsWithColor"
|
||||
:export-fields="exportFields"
|
||||
@selection-change="(items: any[]) => handleSelectionChange(items as Animal[])"
|
||||
@batch-action="(action: string, items: any[], params?: any) => handleBatchAction(action, items as Animal[], params)"
|
||||
/>
|
||||
|
||||
<!-- 动物列表表格 -->
|
||||
<a-card class="table-card">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="animals"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="{ x: 1200 }"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<!-- 动物图片 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'avatar'">
|
||||
<a-avatar
|
||||
:src="record.avatar"
|
||||
:alt="record.name"
|
||||
size="large"
|
||||
shape="square"
|
||||
>
|
||||
{{ record.name?.charAt(0) }}
|
||||
</a-avatar>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 动物类型 -->
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag color="blue">{{ record.type }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 年龄 -->
|
||||
<template v-else-if="column.key === 'age'">
|
||||
{{ record.age }}岁
|
||||
</template>
|
||||
|
||||
<!-- 创建时间 -->
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ formatDate(record.createdAt) }}
|
||||
</template>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewAnimal(record)">
|
||||
查看
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEditAnimal(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<a-button type="link" size="small">
|
||||
更多 <DownOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
key="activate"
|
||||
v-if="record.status !== 'available'"
|
||||
@click="handleUpdateStatus(record, 'available')"
|
||||
>
|
||||
<CheckCircleOutlined />
|
||||
设为可领养
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
key="deactivate"
|
||||
v-if="record.status !== 'unavailable'"
|
||||
@click="handleUpdateStatus(record, 'unavailable')"
|
||||
>
|
||||
<LockOutlined />
|
||||
设为不可领养
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="delete" @click="handleDeleteAnimal(record)">
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 动物详情弹窗 -->
|
||||
<AnimalDetail
|
||||
:animal="currentAnimal"
|
||||
:visible="showAnimalDetail"
|
||||
@update:visible="showAnimalDetail = $event"
|
||||
/>
|
||||
|
||||
<!-- 动物表单弹窗 -->
|
||||
<AnimalForm
|
||||
:animal="currentAnimal"
|
||||
:visible="showAnimalForm"
|
||||
:is-editing="isEditing"
|
||||
@update:visible="showAnimalForm = $event"
|
||||
@submit="handleAnimalSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { SearchOutlined, PlusOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import type { Animal } from '@/api/animal'
|
||||
import animalAPI from '@/api/animal'
|
||||
import AdvancedSearch from '@/components/AdvancedSearch.vue'
|
||||
import BatchOperations from '@/components/BatchOperations.vue'
|
||||
import AnimalForm from './components/AnimalForm.vue'
|
||||
import AnimalDetail from './components/AnimalDetail.vue'
|
||||
|
||||
// 移除重复的Animal接口定义,使用api中的接口
|
||||
|
||||
interface Statistics {
|
||||
totalAnimals: number
|
||||
availableAnimals: number
|
||||
newAnimalsToday: number
|
||||
claimedAnimals: number
|
||||
}
|
||||
|
||||
// 临时格式化函数,直到utils/date模块可用
|
||||
const formatDate = (date: string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const animals = ref<Animal[]>([])
|
||||
const selectedAnimals = ref<Animal[]>([])
|
||||
const currentAnimal = ref<Animal | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const selectedRowKeys = computed(() => selectedAnimals.value.map(animal => animal.id))
|
||||
|
||||
// 统计数据
|
||||
const statistics = reactive<Statistics>({
|
||||
totalAnimals: 0,
|
||||
availableAnimals: 0,
|
||||
newAnimalsToday: 0,
|
||||
claimedAnimals: 0
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number, range: [number, number]) =>
|
||||
`第 ${range[0]}-${range[1]} 条,共 ${total} 条`
|
||||
})
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = reactive({
|
||||
keyword: '',
|
||||
type: '',
|
||||
status: '',
|
||||
age_range: [] as number[],
|
||||
date_range: [] as string[]
|
||||
})
|
||||
|
||||
// 模态框状态
|
||||
const showAnimalDetail = ref(false)
|
||||
const showAnimalForm = ref(false)
|
||||
const isEditing = ref(false)
|
||||
|
||||
// 搜索字段配置
|
||||
const searchFields = [
|
||||
{
|
||||
key: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入动物名称或描述'
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: '动物类型',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '狗', value: '狗' },
|
||||
{ label: '猫', value: '猫' },
|
||||
{ label: '兔子', value: '兔子' },
|
||||
{ label: '鸟类', value: '鸟类' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '可领养', value: 'available' },
|
||||
{ label: '已领养', value: 'adopted' },
|
||||
{ label: '不可领养', value: 'unavailable' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 状态选项
|
||||
const statusOptions = [
|
||||
{ label: '可领养', value: 'available', color: 'green' },
|
||||
{ label: '已领养', value: 'adopted', color: 'blue' },
|
||||
{ label: '不可领养', value: 'unavailable', color: 'red' }
|
||||
]
|
||||
|
||||
// 批量操作用的状态选项
|
||||
const statusOptionsWithColor = [
|
||||
{ label: '可领养', value: 'available', color: 'green' },
|
||||
{ label: '已领养', value: 'adopted', color: 'blue' },
|
||||
{ label: '不可领养', value: 'unavailable', color: 'red' }
|
||||
]
|
||||
|
||||
// 导出字段
|
||||
const exportFields = [
|
||||
{ key: 'name', label: '动物名称' },
|
||||
{ key: 'species', label: '物种' },
|
||||
{ key: 'breed', label: '品种' },
|
||||
{ key: 'age', label: '年龄' },
|
||||
{ key: 'gender', label: '性别' },
|
||||
{ key: 'price', label: '价格' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'merchant_name', label: '商家' },
|
||||
{ key: 'created_at', label: '创建时间' }
|
||||
]
|
||||
|
||||
// 表格列配置
|
||||
const columns: TableColumnsType<Animal> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '图片',
|
||||
dataIndex: 'image',
|
||||
key: 'image',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '物种',
|
||||
dataIndex: 'species',
|
||||
key: 'species',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '品种',
|
||||
dataIndex: 'breed',
|
||||
key: 'breed',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '年龄',
|
||||
dataIndex: 'age',
|
||||
key: 'age',
|
||||
width: 80,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '性别',
|
||||
dataIndex: 'gender',
|
||||
key: 'gender',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
width: 100,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '商家',
|
||||
dataIndex: 'merchant_name',
|
||||
key: 'merchant_name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 160,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 表格行选择配置
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedAnimals.value.map(item => item.id),
|
||||
onChange: (selectedRowKeys: any[]) => {
|
||||
selectedAnimals.value = animals.value.filter(item =>
|
||||
selectedRowKeys.includes(item.id)
|
||||
)
|
||||
}
|
||||
})) as any
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
available: 'green',
|
||||
adopted: 'blue',
|
||||
unavailable: 'red'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
available: '可领养',
|
||||
adopted: '已领养',
|
||||
unavailable: '不可领养'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
// 获取动物列表
|
||||
const fetchAnimals = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
// 模拟数据 - 使用类型断言避免类型检查
|
||||
const mockAnimals = [
|
||||
{
|
||||
id: 1,
|
||||
name: '小白',
|
||||
species: '狗',
|
||||
breed: '金毛',
|
||||
age: 2,
|
||||
gender: '雄性',
|
||||
description: '温顺可爱的金毛犬,性格友善,适合家庭饲养',
|
||||
image: 'https://example.com/dog1.jpg',
|
||||
merchant_id: 1,
|
||||
merchant_name: '爱心宠物店',
|
||||
price: 1500,
|
||||
status: 'available',
|
||||
created_at: '2024-01-15T10:30:00Z',
|
||||
updated_at: '2024-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '小花',
|
||||
species: '猫',
|
||||
breed: '英短',
|
||||
age: 1,
|
||||
gender: '雌性',
|
||||
description: '活泼可爱的英短猫,毛色纯正,健康活泼',
|
||||
image: 'https://example.com/cat1.jpg',
|
||||
merchant_id: 2,
|
||||
merchant_name: '温馨宠物之家',
|
||||
price: 2000,
|
||||
status: 'adopted',
|
||||
created_at: '2024-01-14T14:20:00Z',
|
||||
updated_at: '2024-01-16T09:15:00Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '小黑',
|
||||
species: '狗',
|
||||
breed: '拉布拉多',
|
||||
age: 3,
|
||||
gender: '雄性',
|
||||
description: '聪明忠诚的拉布拉多,训练有素,适合陪伴',
|
||||
image: 'https://example.com/dog2.jpg',
|
||||
merchant_id: 1,
|
||||
merchant_name: '爱心宠物店',
|
||||
price: 1800,
|
||||
status: 'available',
|
||||
created_at: '2024-01-13T16:45:00Z',
|
||||
updated_at: '2024-01-13T16:45:00Z'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '咪咪',
|
||||
species: '猫',
|
||||
breed: '波斯猫',
|
||||
age: 2,
|
||||
gender: '雌性',
|
||||
description: '优雅的波斯猫,毛发柔顺,性格温和',
|
||||
image: 'https://example.com/cat2.jpg',
|
||||
merchant_id: 3,
|
||||
merchant_name: '宠物乐园',
|
||||
price: 2500,
|
||||
status: 'unavailable',
|
||||
created_at: '2024-01-12T11:20:00Z',
|
||||
updated_at: '2024-01-17T14:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '小灰',
|
||||
species: '兔子',
|
||||
breed: '荷兰兔',
|
||||
age: 1,
|
||||
gender: '雄性',
|
||||
description: '可爱的荷兰兔,毛色灰白相间,性格活泼',
|
||||
image: 'https://example.com/rabbit1.jpg',
|
||||
merchant_id: 2,
|
||||
merchant_name: '温馨宠物之家',
|
||||
price: 800,
|
||||
status: 'available',
|
||||
created_at: '2024-01-16T09:10:00Z',
|
||||
updated_at: '2024-01-16T09:10:00Z'
|
||||
}
|
||||
] as Animal[]
|
||||
|
||||
const mockData = {
|
||||
list: mockAnimals,
|
||||
total: mockAnimals.length,
|
||||
statistics: {
|
||||
totalAnimals: 156,
|
||||
availableAnimals: 89,
|
||||
newAnimalsToday: 3,
|
||||
claimedAnimals: 45
|
||||
}
|
||||
}
|
||||
|
||||
animals.value = mockData.list
|
||||
pagination.total = mockData.total
|
||||
|
||||
// 更新统计数据
|
||||
Object.assign(statistics, mockData.statistics)
|
||||
} catch (error) {
|
||||
message.error('获取动物列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = (params: any) => {
|
||||
Object.assign(searchParams, params)
|
||||
pagination.current = 1
|
||||
fetchAnimals()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
Object.assign(searchParams, {
|
||||
keyword: '',
|
||||
type: '',
|
||||
status: '',
|
||||
age_range: [],
|
||||
date_range: []
|
||||
})
|
||||
pagination.current = 1
|
||||
fetchAnimals()
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const handleRefresh = () => {
|
||||
fetchAnimals()
|
||||
}
|
||||
|
||||
// 查看动物详情
|
||||
const handleViewAnimal = (animal: Animal) => {
|
||||
currentAnimal.value = animal
|
||||
showAnimalDetail.value = true
|
||||
}
|
||||
|
||||
// 编辑动物
|
||||
const handleEditAnimal = (animal: Animal) => {
|
||||
currentAnimal.value = animal
|
||||
isEditing.value = true
|
||||
showAnimalForm.value = true
|
||||
}
|
||||
|
||||
// 新增动物
|
||||
const handleAddAnimal = () => {
|
||||
currentAnimal.value = null
|
||||
isEditing.value = false
|
||||
showAnimalForm.value = true
|
||||
}
|
||||
|
||||
// 删除动物
|
||||
const handleDeleteAnimal = (animal: Animal) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除动物 "${animal.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
// 模拟API调用
|
||||
message.success('删除成功')
|
||||
fetchAnimals()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新动物状态
|
||||
const handleUpdateStatus = async (animal: Animal, status: string) => {
|
||||
try {
|
||||
// 模拟API调用
|
||||
message.success('状态更新成功')
|
||||
fetchAnimals()
|
||||
} catch (error) {
|
||||
message.error('状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量选择处理
|
||||
const handleSelectionChange = (items: Animal[]) => {
|
||||
selectedAnimals.value = items
|
||||
}
|
||||
|
||||
// 批量操作处理
|
||||
const handleBatchAction = async (action: string, items: Animal[], params?: any) => {
|
||||
try {
|
||||
const animalIds = items.map(animal => animal.id)
|
||||
|
||||
switch (action) {
|
||||
case 'updateStatus':
|
||||
message.success(`批量${params?.status === 'available' ? '设为可领养' : '更新状态'}成功`)
|
||||
break
|
||||
case 'delete':
|
||||
message.success('批量删除成功')
|
||||
break
|
||||
case 'export':
|
||||
message.success('导出成功')
|
||||
break
|
||||
default:
|
||||
message.warning('未知操作')
|
||||
return
|
||||
}
|
||||
|
||||
selectedAnimals.value = []
|
||||
fetchAnimals()
|
||||
} catch (error) {
|
||||
message.error('批量操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag: any, filters: any, sorter: any) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
|
||||
// TODO: 处理排序和筛选
|
||||
fetchAnimals()
|
||||
}
|
||||
|
||||
// 动物表单提交
|
||||
const handleAnimalSubmit = async (animalData: any) => {
|
||||
try {
|
||||
if (isEditing.value && currentAnimal.value) {
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
message.success('创建成功')
|
||||
}
|
||||
showAnimalForm.value = false
|
||||
fetchAnimals()
|
||||
} catch (error) {
|
||||
message.error(isEditing.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchAnimals()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animals-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.statistics-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover > td) {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
577
admin-system/src/pages/statistics/index.vue
Normal file
577
admin-system/src/pages/statistics/index.vue
Normal file
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<div class="statistics-page">
|
||||
<a-page-header
|
||||
title="数据统计"
|
||||
sub-title="系统数据分析与统计报表"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
@change="handleDateRangeChange"
|
||||
/>
|
||||
<a-button @click="handleExport" :loading="exportLoading">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
导出报表
|
||||
</a-button>
|
||||
<a-button @click="handleRefreshAll" :loading="refreshLoading">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新全部
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 概览统计卡片 -->
|
||||
<a-row :gutter="16" class="overview-cards">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="总用户数"
|
||||
:value="overviewData.totalUsers"
|
||||
:precision="0"
|
||||
suffix="人"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined style="color: #1890ff" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
<div class="statistic-trend">
|
||||
<span :class="['trend', overviewData.userGrowth >= 0 ? 'up' : 'down']">
|
||||
<CaretUpOutlined v-if="overviewData.userGrowth >= 0" />
|
||||
<CaretDownOutlined v-else />
|
||||
{{ Math.abs(overviewData.userGrowth) }}%
|
||||
</span>
|
||||
<span class="trend-text">较昨日</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="活跃用户"
|
||||
:value="overviewData.activeUsers"
|
||||
:precision="0"
|
||||
suffix="人"
|
||||
>
|
||||
<template #prefix>
|
||||
<CheckCircleOutlined style="color: #52c41a" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
<div class="statistic-trend">
|
||||
<span :class="['trend', overviewData.activeGrowth >= 0 ? 'up' : 'down']">
|
||||
<CaretUpOutlined v-if="overviewData.activeGrowth >= 0" />
|
||||
<CaretDownOutlined v-else />
|
||||
{{ Math.abs(overviewData.activeGrowth) }}%
|
||||
</span>
|
||||
<span class="trend-text">较昨日</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="动物认领"
|
||||
:value="overviewData.totalAnimals"
|
||||
:precision="0"
|
||||
suffix="只"
|
||||
>
|
||||
<template #prefix>
|
||||
<HeartOutlined style="color: #eb2f96" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
<div class="statistic-trend">
|
||||
<span :class="['trend', overviewData.animalGrowth >= 0 ? 'up' : 'down']">
|
||||
<CaretUpOutlined v-if="overviewData.animalGrowth >= 0" />
|
||||
<CaretDownOutlined v-else />
|
||||
{{ Math.abs(overviewData.animalGrowth) }}%
|
||||
</span>
|
||||
<span class="trend-text">较昨日</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="总收入"
|
||||
:value="overviewData.totalRevenue"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
>
|
||||
<template #prefix>
|
||||
<DollarOutlined style="color: #faad14" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
<div class="statistic-trend">
|
||||
<span :class="['trend', overviewData.revenueGrowth >= 0 ? 'up' : 'down']">
|
||||
<CaretUpOutlined v-if="overviewData.revenueGrowth >= 0" />
|
||||
<CaretDownOutlined v-else />
|
||||
{{ Math.abs(overviewData.revenueGrowth) }}%
|
||||
</span>
|
||||
<span class="trend-text">较昨日</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<a-row :gutter="16" class="chart-section">
|
||||
<a-col :span="12">
|
||||
<DataStatisticsChart
|
||||
ref="userGrowthChart"
|
||||
title="用户增长趋势"
|
||||
chart-type="area"
|
||||
data-source="/api/v1/admin/statistics/user-growth"
|
||||
:series-config="[
|
||||
{ name: '新增用户', dataKey: 'new_users', color: '#1890ff' },
|
||||
{ name: '累计用户', dataKey: 'cumulative_users', color: '#52c41a' }
|
||||
]"
|
||||
@data-loaded="handleUserGrowthLoaded"
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12">
|
||||
<DataStatisticsChart
|
||||
ref="businessChart"
|
||||
title="业务数据统计"
|
||||
chart-type="line"
|
||||
data-source="/api/v1/admin/statistics/business"
|
||||
:series-config="[
|
||||
{ name: '动物认领', dataKey: 'animals', color: '#eb2f96' },
|
||||
{ name: '旅行计划', dataKey: 'travels', color: '#722ed1' },
|
||||
{ name: '订单数量', dataKey: 'orders', color: '#fa8c16' }
|
||||
]"
|
||||
@data-loaded="handleBusinessDataLoaded"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" class="chart-section">
|
||||
<a-col :span="8">
|
||||
<DataStatisticsChart
|
||||
ref="userTypeChart"
|
||||
title="用户类型分布"
|
||||
chart-type="pie"
|
||||
data-source="/api/v1/admin/statistics/user-types"
|
||||
@data-loaded="handleUserTypeLoaded"
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="8">
|
||||
<DataStatisticsChart
|
||||
ref="animalSpeciesChart"
|
||||
title="动物种类分布"
|
||||
chart-type="pie"
|
||||
data-source="/api/v1/admin/statistics/animal-species"
|
||||
@data-loaded="handleAnimalSpeciesLoaded"
|
||||
/>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="8">
|
||||
<DataStatisticsChart
|
||||
ref="revenueChart"
|
||||
title="收入统计"
|
||||
chart-type="bar"
|
||||
data-source="/api/v1/admin/statistics/revenue"
|
||||
:series-config="[
|
||||
{ name: '认领费用', dataKey: 'adoption_fee', color: '#1890ff' },
|
||||
{ name: '推广佣金', dataKey: 'commission', color: '#52c41a' }
|
||||
]"
|
||||
@data-loaded="handleRevenueLoaded"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 地理分布图 -->
|
||||
<a-row :gutter="16" class="chart-section">
|
||||
<a-col :span="24">
|
||||
<a-card title="用户地理分布" :loading="geoLoading">
|
||||
<template #extra>
|
||||
<a-radio-group v-model:value="geoViewType" @change="handleGeoViewChange">
|
||||
<a-radio-button value="users">用户分布</a-radio-button>
|
||||
<a-radio-button value="animals">动物分布</a-radio-button>
|
||||
<a-radio-button value="orders">订单分布</a-radio-button>
|
||||
</a-radio-group>
|
||||
</template>
|
||||
|
||||
<div ref="geoChartContainer" style="height: 400px;"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-row :gutter="16" class="table-section">
|
||||
<a-col :span="12">
|
||||
<a-card title="热门动物排行" size="small">
|
||||
<a-table
|
||||
:columns="animalRankingColumns"
|
||||
:data-source="animalRankingData"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'rank'">
|
||||
<a-tag :color="index < 3 ? ['gold', 'silver', '#cd7f32'][index] : 'default'">
|
||||
{{ index + 1 }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'adoption_rate'">
|
||||
<a-progress
|
||||
:percent="record.adoption_rate"
|
||||
size="small"
|
||||
:show-info="false"
|
||||
/>
|
||||
{{ record.adoption_rate }}%
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="12">
|
||||
<a-card title="活跃用户排行" size="small">
|
||||
<a-table
|
||||
:columns="userRankingColumns"
|
||||
:data-source="userRankingData"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'rank'">
|
||||
<a-tag :color="index < 3 ? ['gold', 'silver', '#cd7f32'][index] : 'default'">
|
||||
{{ index + 1 }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'avatar'">
|
||||
<a-avatar :src="record.avatar" size="small">
|
||||
{{ record.nickname?.charAt(0) }}
|
||||
</a-avatar>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import {
|
||||
UserOutlined,
|
||||
CheckCircleOutlined,
|
||||
HeartOutlined,
|
||||
DollarOutlined,
|
||||
CaretUpOutlined,
|
||||
CaretDownOutlined,
|
||||
DownloadOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import * as echarts from 'echarts'
|
||||
import DataStatisticsChart from '@/components/charts/DataStatisticsChart.vue'
|
||||
|
||||
// 概览数据
|
||||
const overviewData = ref({
|
||||
totalUsers: 12580,
|
||||
activeUsers: 8960,
|
||||
totalAnimals: 1250,
|
||||
totalRevenue: 156780.50,
|
||||
userGrowth: 12.5,
|
||||
activeGrowth: 8.3,
|
||||
animalGrowth: 15.2,
|
||||
revenueGrowth: 22.1
|
||||
})
|
||||
|
||||
// 日期范围
|
||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
|
||||
|
||||
// 加载状态
|
||||
const exportLoading = ref(false)
|
||||
const refreshLoading = ref(false)
|
||||
const geoLoading = ref(false)
|
||||
|
||||
// 地理分布图
|
||||
const geoViewType = ref('users')
|
||||
const geoChartContainer = ref<HTMLDivElement>()
|
||||
let geoChartInstance: echarts.ECharts | null = null
|
||||
|
||||
// 图表引用
|
||||
const userGrowthChart = ref()
|
||||
const businessChart = ref()
|
||||
const userTypeChart = ref()
|
||||
const animalSpeciesChart = ref()
|
||||
const revenueChart = ref()
|
||||
|
||||
// 动物排行数据
|
||||
const animalRankingColumns = [
|
||||
{ title: '排名', dataIndex: 'rank', key: 'rank', width: 80 },
|
||||
{ title: '动物名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '种类', dataIndex: 'species', key: 'species' },
|
||||
{ title: '认领次数', dataIndex: 'adoption_count', key: 'adoption_count' },
|
||||
{ title: '认领率', dataIndex: 'adoption_rate', key: 'adoption_rate' }
|
||||
]
|
||||
|
||||
const animalRankingData = ref([
|
||||
{ id: 1, name: '小白', species: '狗', adoption_count: 25, adoption_rate: 85 },
|
||||
{ id: 2, name: '咪咪', species: '猫', adoption_count: 22, adoption_rate: 78 },
|
||||
{ id: 3, name: '小黑', species: '狗', adoption_count: 20, adoption_rate: 72 },
|
||||
{ id: 4, name: '花花', species: '猫', adoption_count: 18, adoption_rate: 65 },
|
||||
{ id: 5, name: '豆豆', species: '兔子', adoption_count: 15, adoption_rate: 58 }
|
||||
])
|
||||
|
||||
// 用户排行数据
|
||||
const userRankingColumns = [
|
||||
{ title: '排名', dataIndex: 'rank', key: 'rank', width: 80 },
|
||||
{ title: '头像', dataIndex: 'avatar', key: 'avatar', width: 60 },
|
||||
{ title: '用户名', dataIndex: 'nickname', key: 'nickname' },
|
||||
{ title: '认领数量', dataIndex: 'adoption_count', key: 'adoption_count' },
|
||||
{ title: '活跃度', dataIndex: 'activity_score', key: 'activity_score' }
|
||||
]
|
||||
|
||||
const userRankingData = ref([
|
||||
{ id: 1, nickname: '爱心天使', avatar: '', adoption_count: 8, activity_score: 95 },
|
||||
{ id: 2, nickname: '动物守护者', avatar: '', adoption_count: 6, activity_score: 88 },
|
||||
{ id: 3, nickname: '温暖之家', avatar: '', adoption_count: 5, activity_score: 82 },
|
||||
{ id: 4, nickname: '小动物之友', avatar: '', adoption_count: 4, activity_score: 76 },
|
||||
{ id: 5, nickname: '爱宠人士', avatar: '', adoption_count: 3, activity_score: 70 }
|
||||
])
|
||||
|
||||
/**
|
||||
* 处理日期范围变化
|
||||
*/
|
||||
const handleDateRangeChange = (dates: [Dayjs, Dayjs] | null) => {
|
||||
if (dates) {
|
||||
// 刷新所有图表数据
|
||||
handleRefreshAll()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出报表
|
||||
*/
|
||||
const handleExport = async () => {
|
||||
exportLoading.value = true
|
||||
|
||||
try {
|
||||
// 模拟导出过程
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 这里应该调用实际的导出API
|
||||
message.success('报表导出成功')
|
||||
} catch (error) {
|
||||
message.error('报表导出失败')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新所有数据
|
||||
*/
|
||||
const handleRefreshAll = async () => {
|
||||
refreshLoading.value = true
|
||||
|
||||
try {
|
||||
// 刷新概览数据
|
||||
await loadOverviewData()
|
||||
|
||||
// 刷新所有图表
|
||||
userGrowthChart.value?.refresh()
|
||||
businessChart.value?.refresh()
|
||||
userTypeChart.value?.refresh()
|
||||
animalSpeciesChart.value?.refresh()
|
||||
revenueChart.value?.refresh()
|
||||
|
||||
// 刷新地理分布图
|
||||
await loadGeoData()
|
||||
|
||||
message.success('数据刷新成功')
|
||||
} catch (error) {
|
||||
message.error('数据刷新失败')
|
||||
} finally {
|
||||
refreshLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载概览数据
|
||||
*/
|
||||
const loadOverviewData = async () => {
|
||||
// 这里应该调用实际的API接口
|
||||
// const response = await getOverviewStatistics()
|
||||
// overviewData.value = response.data
|
||||
|
||||
// 模拟数据更新
|
||||
overviewData.value = {
|
||||
...overviewData.value,
|
||||
totalUsers: overviewData.value.totalUsers + Math.floor(Math.random() * 100),
|
||||
activeUsers: overviewData.value.activeUsers + Math.floor(Math.random() * 50)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化地理分布图
|
||||
*/
|
||||
const initGeoChart = () => {
|
||||
if (!geoChartContainer.value) return
|
||||
|
||||
geoChartInstance = echarts.init(geoChartContainer.value)
|
||||
loadGeoData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载地理分布数据
|
||||
*/
|
||||
const loadGeoData = async () => {
|
||||
if (!geoChartInstance) return
|
||||
|
||||
geoLoading.value = true
|
||||
|
||||
try {
|
||||
// 模拟地理数据
|
||||
const geoData = [
|
||||
{ name: '北京', value: 1200 },
|
||||
{ name: '上海', value: 980 },
|
||||
{ name: '广东', value: 850 },
|
||||
{ name: '浙江', value: 720 },
|
||||
{ name: '江苏', value: 680 },
|
||||
{ name: '四川', value: 520 },
|
||||
{ name: '湖北', value: 450 },
|
||||
{ name: '河南', value: 380 }
|
||||
]
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: `${geoViewType.value === 'users' ? '用户' : geoViewType.value === 'animals' ? '动物' : '订单'}分布`,
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c}'
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: 1200,
|
||||
left: 'left',
|
||||
top: 'bottom',
|
||||
text: ['高', '低'],
|
||||
calculable: true,
|
||||
inRange: {
|
||||
color: ['#e0f3ff', '#006edd']
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: geoViewType.value,
|
||||
type: 'map',
|
||||
map: 'china',
|
||||
roam: false,
|
||||
data: geoData
|
||||
}]
|
||||
}
|
||||
|
||||
geoChartInstance.setOption(option)
|
||||
} catch (error) {
|
||||
console.error('加载地理数据失败:', error)
|
||||
} finally {
|
||||
geoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理地理视图类型变化
|
||||
*/
|
||||
const handleGeoViewChange = () => {
|
||||
loadGeoData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 图表数据加载回调
|
||||
*/
|
||||
const handleUserGrowthLoaded = (data: any[]) => {
|
||||
console.log('用户增长数据加载完成:', data)
|
||||
}
|
||||
|
||||
const handleBusinessDataLoaded = (data: any[]) => {
|
||||
console.log('业务数据加载完成:', data)
|
||||
}
|
||||
|
||||
const handleUserTypeLoaded = (data: any[]) => {
|
||||
console.log('用户类型数据加载完成:', data)
|
||||
}
|
||||
|
||||
const handleAnimalSpeciesLoaded = (data: any[]) => {
|
||||
console.log('动物种类数据加载完成:', data)
|
||||
}
|
||||
|
||||
const handleRevenueLoaded = (data: any[]) => {
|
||||
console.log('收入数据加载完成:', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件挂载
|
||||
*/
|
||||
onMounted(() => {
|
||||
loadOverviewData()
|
||||
|
||||
nextTick(() => {
|
||||
initGeoChart()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.statistics-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.overview-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.statistic-trend {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.trend {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.trend.up {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.trend.down {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.trend-text {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-section :deep(.ant-card),
|
||||
.table-section :deep(.ant-card) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-section :deep(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
172
admin-system/src/pages/users/components/UserDetail.vue
Normal file
172
admin-system/src/pages/users/components/UserDetail.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
title="用户详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div v-if="user" class="user-detail">
|
||||
<!-- 基本信息 -->
|
||||
<a-card title="基本信息" class="mb-4">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>用户ID:</label>
|
||||
<span>{{ user.id }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>用户名:</label>
|
||||
<span>{{ user.username }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>昵称:</label>
|
||||
<span>{{ user.nickname || '-' }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>邮箱:</label>
|
||||
<span>{{ user.email || '-' }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>手机号:</label>
|
||||
<span>{{ user.phone || '-' }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>状态:</label>
|
||||
<a-tag :color="getStatusColor(user.status)">
|
||||
{{ getStatusText(user.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<a-card title="统计信息" class="mb-4">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-statistic title="积分" :value="user.points" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic title="等级" :value="user.level" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic title="余额" :value="user.balance" prefix="¥" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic title="旅行次数" :value="user.travel_count" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<a-card title="时间信息">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>注册时间:</label>
|
||||
<span>{{ formatDate(user.created_at) }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>更新时间:</label>
|
||||
<span>{{ formatDate(user.updated_at) }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-item">
|
||||
<label>最后登录:</label>
|
||||
<span>{{ user.last_login_at ? formatDate(user.last_login_at) : '-' }}</span>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { User } from '@/api/user'
|
||||
|
||||
interface Props {
|
||||
user: User | null
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
active: 'green',
|
||||
inactive: 'red',
|
||||
pending: 'orange'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
active: '正常',
|
||||
inactive: '禁用',
|
||||
pending: '待审核'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-detail {
|
||||
.detail-item {
|
||||
margin-bottom: 12px;
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
276
admin-system/src/pages/users/components/UserForm.vue
Normal file
276
admin-system/src/pages/users/components/UserForm.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="isEditing ? '编辑用户' : '新增用户'"
|
||||
width="600px"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="用户名" name="username">
|
||||
<a-input
|
||||
v-model:value="formData.username"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="isEditing"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="昵称" name="nickname">
|
||||
<a-input
|
||||
v-model:value="formData.nickname"
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input
|
||||
v-model:value="formData.email"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="手机号" name="phone">
|
||||
<a-input
|
||||
v-model:value="formData.phone"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" v-if="!isEditing">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="密码" name="password">
|
||||
<a-input-password
|
||||
v-model:value="formData.password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="确认密码" name="confirmPassword">
|
||||
<a-input-password
|
||||
v-model:value="formData.confirmPassword"
|
||||
placeholder="请确认密码"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="性别" name="gender">
|
||||
<a-select
|
||||
v-model:value="formData.gender"
|
||||
placeholder="请选择性别"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option :value="1">男</a-select-option>
|
||||
<a-select-option :value="2">女</a-select-option>
|
||||
<a-select-option :value="0">未知</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="生日" name="birthday">
|
||||
<a-date-picker
|
||||
v-model:value="formData.birthday"
|
||||
placeholder="请选择生日"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select
|
||||
v-model:value="formData.status"
|
||||
placeholder="请选择状态"
|
||||
>
|
||||
<a-select-option value="active">正常</a-select-option>
|
||||
<a-select-option value="inactive">禁用</a-select-option>
|
||||
<a-select-option value="pending">待审核</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-input
|
||||
v-model:value="formData.remark"
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
|
||||
import type { User } from '@/api/user'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
|
||||
interface Props {
|
||||
user: User | null
|
||||
visible: boolean
|
||||
isEditing: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'submit', data: any): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
gender: undefined as number | undefined,
|
||||
birthday: undefined as Dayjs | undefined,
|
||||
status: 'active',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules: Record<string, Rule[]> = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: !props.isEditing, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度为6-20个字符', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: !props.isEditing, message: '请确认密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule: any, value: string) => {
|
||||
if (!props.isEditing && value !== formData.password) {
|
||||
return Promise.reject('两次输入的密码不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 监听用户数据变化,初始化表单
|
||||
watch(() => props.user, (user) => {
|
||||
if (user && props.isEditing) {
|
||||
formData.username = user.username
|
||||
formData.nickname = user.nickname
|
||||
formData.email = user.email
|
||||
formData.phone = user.phone
|
||||
formData.gender = user.gender
|
||||
formData.birthday = user.birthday ? dayjs(user.birthday) : undefined
|
||||
formData.status = user.status
|
||||
formData.remark = user.remark
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听弹窗显示状态,重置表单
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible && !props.isEditing) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
username: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
gender: undefined,
|
||||
birthday: undefined,
|
||||
status: 'active',
|
||||
remark: ''
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
|
||||
const submitData: any = {
|
||||
username: formData.username,
|
||||
nickname: formData.nickname,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
gender: formData.gender,
|
||||
birthday: formData.birthday?.format('YYYY-MM-DD'),
|
||||
status: formData.status,
|
||||
remark: formData.remark
|
||||
}
|
||||
|
||||
if (!props.isEditing) {
|
||||
submitData.password = formData.password
|
||||
}
|
||||
|
||||
emit('submit', submitData)
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
843
admin-system/src/pages/users/index.vue
Normal file
843
admin-system/src/pages/users/index.vue
Normal file
@@ -0,0 +1,843 @@
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<a-card title="用户管理" size="small">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined />
|
||||
新增用户
|
||||
</a-button>
|
||||
<a-button @click="handleRefresh">
|
||||
<ReloadOutlined />
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic
|
||||
title="总用户数"
|
||||
:value="statistics.totalUsers"
|
||||
:value-style="{ color: '#3f8600' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic
|
||||
title="活跃用户"
|
||||
:value="statistics.activeUsers"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<CheckCircleOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic
|
||||
title="今日新增"
|
||||
:value="statistics.todayNew"
|
||||
:value-style="{ color: '#722ed1' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<PlusCircleOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic
|
||||
title="VIP用户"
|
||||
:value="statistics.vipUsers"
|
||||
:value-style="{ color: '#fa8c16' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<CrownOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 高级搜索 -->
|
||||
<AdvancedSearch
|
||||
search-type="user"
|
||||
:status-options="statusOptions"
|
||||
@search="handleSearch"
|
||||
@reset="handleSearchReset"
|
||||
/>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<BatchOperations
|
||||
:data-source="users"
|
||||
:selected-items="selectedUsers"
|
||||
operation-type="user"
|
||||
:status-options="statusOptions"
|
||||
:export-fields="exportFields"
|
||||
@selection-change="(items: any[]) => handleSelectionChange(items as User[])"
|
||||
@batch-action="(action: string, items: any[], params?: any) => handleBatchAction(action, items as User[], params)"
|
||||
/>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="{ x: 1200 }"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<!-- 头像列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'avatar'">
|
||||
<a-avatar :src="record.avatar" :size="40">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
</template>
|
||||
|
||||
<!-- 用户信息列 -->
|
||||
<template v-else-if="column.key === 'userInfo'">
|
||||
<div class="user-info">
|
||||
<div class="user-name">
|
||||
{{ record.nickname || record.username }}
|
||||
<a-tag v-if="record.user_type === 'vip'" color="gold" size="small">
|
||||
<CrownOutlined />
|
||||
VIP
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="user-meta">
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
ID: {{ record.id }} | {{ record.phone || record.email }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 注册信息列 -->
|
||||
<template v-else-if="column.key === 'registerInfo'">
|
||||
<div class="register-info">
|
||||
<div>
|
||||
<a-tag :color="getSourceColor(record.register_source)" size="small">
|
||||
{{ getSourceText(record.register_source) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="register-time">
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 最后登录列 -->
|
||||
<template v-else-if="column.key === 'lastLogin'">
|
||||
<div v-if="record.last_login_at">
|
||||
<div>{{ formatDate(record.last_login_at) }}</div>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
{{ record.last_login_ip }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-typography-text v-else type="secondary">
|
||||
从未登录
|
||||
</a-typography-text>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
查看
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => handleMenuAction(key, record)">
|
||||
<a-menu-item key="resetPassword">
|
||||
<KeyOutlined />
|
||||
重置密码
|
||||
</a-menu-item>
|
||||
<a-menu-item key="sendMessage">
|
||||
<MessageOutlined />
|
||||
发送消息
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
:key="record.status === 'active' ? 'disable' : 'enable'"
|
||||
>
|
||||
<component
|
||||
:is="record.status === 'active' ? LockOutlined : UnlockOutlined"
|
||||
/>
|
||||
{{ record.status === 'active' ? '禁用' : '启用' }}
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="delete" class="danger-item">
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button type="link" size="small">
|
||||
更多
|
||||
<DownOutlined />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 用户详情模态框 -->
|
||||
<a-modal
|
||||
v-model:open="detailModalVisible"
|
||||
title="用户详情"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
>
|
||||
<UserDetail
|
||||
v-if="currentUser"
|
||||
:user="currentUser"
|
||||
@refresh="handleRefresh"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<!-- 用户编辑模态框 -->
|
||||
<a-modal
|
||||
v-model:open="editModalVisible"
|
||||
title="编辑用户"
|
||||
@ok="handleEditSubmit"
|
||||
:confirm-loading="editLoading"
|
||||
>
|
||||
<UserForm
|
||||
v-if="currentUser"
|
||||
ref="userFormRef"
|
||||
:user="currentUser"
|
||||
mode="edit"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<!-- 新增用户模态框 -->
|
||||
<a-modal
|
||||
v-model:open="addModalVisible"
|
||||
title="新增用户"
|
||||
@ok="handleAddSubmit"
|
||||
:confirm-loading="addLoading"
|
||||
>
|
||||
<UserForm
|
||||
ref="addUserFormRef"
|
||||
mode="add"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<!-- 发送消息模态框 -->
|
||||
<a-modal
|
||||
v-model:open="messageModalVisible"
|
||||
title="发送消息"
|
||||
@ok="handleSendMessage"
|
||||
:confirm-loading="messageLoading"
|
||||
>
|
||||
<a-form :model="messageForm" layout="vertical">
|
||||
<a-form-item label="消息标题" required>
|
||||
<a-input
|
||||
v-model:value="messageForm.title"
|
||||
placeholder="请输入消息标题"
|
||||
:maxlength="100"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="消息内容" required>
|
||||
<a-textarea
|
||||
v-model:value="messageForm.content"
|
||||
placeholder="请输入消息内容"
|
||||
:rows="4"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="消息类型">
|
||||
<a-radio-group v-model:value="messageForm.type">
|
||||
<a-radio value="info">通知</a-radio>
|
||||
<a-radio value="warning">警告</a-radio>
|
||||
<a-radio value="promotion">推广</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import {
|
||||
UserOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
PlusCircleOutlined,
|
||||
CrownOutlined,
|
||||
KeyOutlined,
|
||||
MessageOutlined,
|
||||
LockOutlined,
|
||||
UnlockOutlined,
|
||||
DeleteOutlined,
|
||||
DownOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import type { TableColumnsType, TableProps } from 'ant-design-vue'
|
||||
import AdvancedSearch from '@/components/AdvancedSearch.vue'
|
||||
import BatchOperations from '@/components/BatchOperations.vue'
|
||||
import UserDetail from './components/UserDetail.vue'
|
||||
import UserForm from './components/UserForm.vue'
|
||||
import userAPI, { type User } from '@/api/user'
|
||||
// import { formatDate } from '@/utils/date'
|
||||
|
||||
interface Statistics {
|
||||
totalUsers: number
|
||||
activeUsers: number
|
||||
newUsersToday: number
|
||||
totalRevenue: number
|
||||
}
|
||||
|
||||
// 临时格式化函数,直到utils/date模块可用
|
||||
const formatDate = (date: string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const users = ref<User[]>([])
|
||||
const selectedUsers = ref<User[]>([])
|
||||
const currentUser = ref<User | null>(null)
|
||||
|
||||
// 统计数据
|
||||
const statistics = ref<Statistics>({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
todayNew: 0,
|
||||
vipUsers: 0
|
||||
})
|
||||
|
||||
// 模态框状态
|
||||
const detailModalVisible = ref(false)
|
||||
const editModalVisible = ref(false)
|
||||
const addModalVisible = ref(false)
|
||||
const messageModalVisible = ref(false)
|
||||
|
||||
// 加载状态
|
||||
const editLoading = ref(false)
|
||||
const addLoading = ref(false)
|
||||
const messageLoading = ref(false)
|
||||
|
||||
// 表单引用
|
||||
const userFormRef = ref()
|
||||
const addUserFormRef = ref()
|
||||
|
||||
// 消息表单
|
||||
const messageForm = reactive({
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'info'
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条记录`
|
||||
})
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = ref({})
|
||||
|
||||
// 状态选项
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: '激活', color: 'green' },
|
||||
{ value: 'inactive', label: '禁用', color: 'red' },
|
||||
{ value: 'pending', label: '待审核', color: 'orange' }
|
||||
]
|
||||
|
||||
// 导出字段
|
||||
const exportFields = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'username', label: '用户名' },
|
||||
{ key: 'nickname', label: '昵称' },
|
||||
{ key: 'email', label: '邮箱' },
|
||||
{ key: 'phone', label: '手机号' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'user_type', label: '用户类型' },
|
||||
{ key: 'register_source', label: '注册来源' },
|
||||
{ key: 'created_at', label: '注册时间' },
|
||||
{ key: 'last_login_at', label: '最后登录' }
|
||||
]
|
||||
|
||||
// 表格列配置
|
||||
const columns: TableColumnsType = [
|
||||
{
|
||||
title: '头像',
|
||||
key: 'avatar',
|
||||
width: 80,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '用户信息',
|
||||
key: 'userInfo',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '注册信息',
|
||||
key: 'registerInfo',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
key: 'lastLogin',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection: TableProps['rowSelection'] = {
|
||||
selectedRowKeys: computed(() => selectedUsers.value.map(user => user.id)),
|
||||
onChange: (selectedRowKeys: (string | number)[], selectedRows: User[]) => {
|
||||
selectedUsers.value = selectedRows
|
||||
},
|
||||
onSelectAll: (selected: boolean, selectedRows: User[], changeRows: User[]) => {
|
||||
if (selected) {
|
||||
selectedUsers.value = [...selectedUsers.value, ...changeRows]
|
||||
} else {
|
||||
const changeIds = changeRows.map(row => row.id)
|
||||
selectedUsers.value = selectedUsers.value.filter(user => !changeIds.includes(user.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态颜色
|
||||
*/
|
||||
const getStatusColor = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
active: 'green',
|
||||
inactive: 'red',
|
||||
pending: 'orange'
|
||||
}
|
||||
return statusMap[status] || 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态文本
|
||||
*/
|
||||
const getStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
active: '激活',
|
||||
inactive: '禁用',
|
||||
pending: '待审核'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取来源颜色
|
||||
*/
|
||||
const getSourceColor = (source: string) => {
|
||||
const sourceMap: Record<string, string> = {
|
||||
web: 'blue',
|
||||
wechat: 'green',
|
||||
app: 'purple'
|
||||
}
|
||||
return sourceMap[source] || 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取来源文本
|
||||
*/
|
||||
const getSourceText = (source: string) => {
|
||||
const sourceMap: Record<string, string> = {
|
||||
web: '网页端',
|
||||
wechat: '微信小程序',
|
||||
app: '移动应用'
|
||||
}
|
||||
return sourceMap[source] || source
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载用户列表
|
||||
*/
|
||||
const loadUsers = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.current,
|
||||
pageSize: pagination.value.pageSize,
|
||||
...searchParams.value
|
||||
}
|
||||
|
||||
const response = await userAPI.getUsers(params)
|
||||
|
||||
users.value = response.data.list
|
||||
pagination.value.total = response.data.total
|
||||
|
||||
// 更新统计数据
|
||||
statistics.value = response.data.statistics || {
|
||||
totalUsers: response.data.total,
|
||||
activeUsers: response.data.list.filter((u: User) => u.status === 'active').length,
|
||||
todayNew: 0,
|
||||
vipUsers: response.data.list.filter((u: User) => u.user_type === 'vip').length
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理表格变化
|
||||
*/
|
||||
const handleTableChange: TableProps['onChange'] = (pag) => {
|
||||
if (pag) {
|
||||
pagination.value.current = pag.current || 1
|
||||
pagination.value.pageSize = pag.pageSize || 20
|
||||
}
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
*/
|
||||
const handleSearch = (params: any) => {
|
||||
searchParams.value = params
|
||||
pagination.value.current = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索重置
|
||||
*/
|
||||
const handleSearchReset = () => {
|
||||
searchParams.value = {}
|
||||
pagination.value.current = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理选择变化
|
||||
*/
|
||||
const handleSelectionChange = (items: User[]) => {
|
||||
selectedUsers.value = items
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量操作
|
||||
*/
|
||||
const handleBatchAction = async (action: string, items: User[], params?: any) => {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'update-status':
|
||||
await userAPI.batchUpdateStatus(
|
||||
items.map(item => item.id),
|
||||
params.status,
|
||||
params.reason
|
||||
)
|
||||
message.success('批量状态更新成功')
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
await userAPI.batchDelete(items.map(item => item.id))
|
||||
message.success('批量删除成功')
|
||||
break
|
||||
|
||||
case 'export':
|
||||
await userAPI.exportUsers(items.map(item => item.id), params)
|
||||
message.success('导出任务已开始')
|
||||
break
|
||||
|
||||
case 'send-message':
|
||||
// 打开批量发送消息界面
|
||||
messageModalVisible.value = true
|
||||
break
|
||||
|
||||
case 'lock':
|
||||
await userAPI.batchUpdateStatus(items.map(item => item.id), 'inactive', '批量锁定')
|
||||
message.success('批量锁定成功')
|
||||
break
|
||||
|
||||
case 'unlock':
|
||||
await userAPI.batchUpdateStatus(items.map(item => item.id), 'active', '批量解锁')
|
||||
message.success('批量解锁成功')
|
||||
break
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
loadUsers()
|
||||
// 清空选择
|
||||
selectedUsers.value = []
|
||||
} catch (error) {
|
||||
message.error('批量操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理查看
|
||||
*/
|
||||
const handleView = (user: User) => {
|
||||
currentUser.value = user
|
||||
detailModalVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理编辑
|
||||
*/
|
||||
const handleEdit = (user: User) => {
|
||||
currentUser.value = user
|
||||
editModalVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新增
|
||||
*/
|
||||
const handleAdd = () => {
|
||||
addModalVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理刷新
|
||||
*/
|
||||
const handleRefresh = () => {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单操作
|
||||
*/
|
||||
const handleMenuAction = async (key: string, user: User) => {
|
||||
switch (key) {
|
||||
case 'resetPassword':
|
||||
Modal.confirm({
|
||||
title: '确认重置密码',
|
||||
content: `确定要重置用户 ${user.nickname || user.username} 的密码吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userAPI.resetPassword(user.id)
|
||||
message.success('密码重置成功')
|
||||
} catch (error) {
|
||||
message.error('密码重置失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'sendMessage':
|
||||
currentUser.value = user
|
||||
messageModalVisible.value = true
|
||||
break
|
||||
|
||||
case 'enable':
|
||||
case 'disable':
|
||||
const newStatus = key === 'enable' ? 'active' : 'inactive'
|
||||
const action = key === 'enable' ? '启用' : '禁用'
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认${action}用户`,
|
||||
content: `确定要${action}用户 ${user.nickname || user.username} 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userAPI.updateStatus(user.id, newStatus)
|
||||
message.success(`${action}成功`)
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
message.error(`${action}失败`)
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
Modal.confirm({
|
||||
title: '确认删除用户',
|
||||
content: `确定要删除用户 ${user.nickname || user.username} 吗?此操作不可撤销!`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userAPI.deleteUser(user.id)
|
||||
message.success('删除成功')
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理编辑提交
|
||||
*/
|
||||
const handleEditSubmit = async () => {
|
||||
if (!userFormRef.value) return
|
||||
|
||||
editLoading.value = true
|
||||
|
||||
try {
|
||||
const formData = await userFormRef.value.validate()
|
||||
await userAPI.updateUser(currentUser.value!.id, formData)
|
||||
|
||||
message.success('更新成功')
|
||||
editModalVisible.value = false
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
message.error('更新失败')
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新增提交
|
||||
*/
|
||||
const handleAddSubmit = async () => {
|
||||
if (!addUserFormRef.value) return
|
||||
|
||||
addLoading.value = true
|
||||
|
||||
try {
|
||||
const formData = await addUserFormRef.value.validate()
|
||||
await userAPI.createUser(formData)
|
||||
|
||||
message.success('创建成功')
|
||||
addModalVisible.value = false
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
message.error('创建失败')
|
||||
} finally {
|
||||
addLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理发送消息
|
||||
*/
|
||||
const handleSendMessage = async () => {
|
||||
if (!messageForm.title || !messageForm.content) {
|
||||
message.error('请填写完整的消息信息')
|
||||
return
|
||||
}
|
||||
|
||||
messageLoading.value = true
|
||||
|
||||
try {
|
||||
const userIds = currentUser.value
|
||||
? [currentUser.value.id]
|
||||
: selectedUsers.value.map(user => user.id)
|
||||
|
||||
await userAPI.sendMessage(userIds, messageForm)
|
||||
|
||||
message.success('消息发送成功')
|
||||
messageModalVisible.value = false
|
||||
|
||||
// 重置表单
|
||||
messageForm.title = ''
|
||||
messageForm.content = ''
|
||||
messageForm.type = 'info'
|
||||
} catch (error) {
|
||||
message.error('消息发送失败')
|
||||
} finally {
|
||||
messageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.register-info {
|
||||
.register-time {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.danger-item) {
|
||||
color: #ff4d4f !important;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.users-page {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.stats-cards :deep(.ant-col) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
166
admin-system/src/utils/date.ts
Normal file
166
admin-system/src/utils/date.ts
Normal file
@@ -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
|
||||
}
|
||||
236
backend/scripts/animal_claims_table.sql
Normal file
236
backend/scripts/animal_claims_table.sql
Normal file
@@ -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();
|
||||
70
backend/scripts/payments_table.sql
Normal file
70
backend/scripts/payments_table.sql
Normal file
@@ -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`);
|
||||
@@ -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处理
|
||||
|
||||
431
backend/src/controllers/admin/animalManagement.js
Normal file
431
backend/src/controllers/admin/animalManagement.js
Normal file
@@ -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;
|
||||
609
backend/src/controllers/admin/dataStatistics.js
Normal file
609
backend/src/controllers/admin/dataStatistics.js
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
590
backend/src/controllers/admin/fileManagement.js
Normal file
590
backend/src/controllers/admin/fileManagement.js
Normal file
@@ -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
|
||||
};
|
||||
487
backend/src/controllers/admin/userManagement.js
Normal file
487
backend/src/controllers/admin/userManagement.js
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
438
backend/src/controllers/animalClaim.js
Normal file
438
backend/src/controllers/animalClaim.js
Normal file
@@ -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();
|
||||
@@ -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: `
|
||||
<h2>邮箱验证</h2>
|
||||
<p>您的验证码是:<strong>${verificationCode}</strong></p>
|
||||
<p>验证码将在10分钟后过期,请及时使用。</p>
|
||||
<p>如果这不是您的操作,请忽略此邮件。</p>
|
||||
`
|
||||
});
|
||||
|
||||
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: `
|
||||
<h2>密码重置</h2>
|
||||
<p>您请求重置密码,请点击下面的链接重置您的密码:</p>
|
||||
<a href="${process.env.FRONTEND_URL}/reset-password?token=${resetToken}">重置密码</a>
|
||||
<p>此链接将在30分钟后过期。</p>
|
||||
<p>如果这不是您的操作,请忽略此邮件。</p>
|
||||
`
|
||||
});
|
||||
|
||||
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
|
||||
};
|
||||
@@ -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 || '支付订单失败'
|
||||
|
||||
371
backend/src/controllers/payment.js
Normal file
371
backend/src/controllers/payment.js
Normal file
@@ -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(`
|
||||
<xml>
|
||||
<return_code><![CDATA[SUCCESS]]></return_code>
|
||||
<return_msg><![CDATA[OK]]></return_msg>
|
||||
</xml>
|
||||
`);
|
||||
} catch (error) {
|
||||
console.error('处理微信支付回调错误:', error);
|
||||
res.set('Content-Type', 'application/xml');
|
||||
res.send(`
|
||||
<xml>
|
||||
<return_code><![CDATA[FAIL]]></return_code>
|
||||
<return_msg><![CDATA[${error.message}]]></return_msg>
|
||||
</xml>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付回调(支付宝)
|
||||
* @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();
|
||||
163
backend/src/controllers/travelRegistration.js
Normal file
163
backend/src/controllers/travelRegistration.js
Normal file
@@ -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;
|
||||
261
backend/src/middleware/errorHandler.js
Normal file
261
backend/src/middleware/errorHandler.js
Normal file
@@ -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
|
||||
};
|
||||
488
backend/src/middleware/upload.js
Normal file
488
backend/src/middleware/upload.js
Normal file
@@ -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<boolean>} 删除结果
|
||||
*/
|
||||
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
|
||||
};
|
||||
582
backend/src/models/AnimalClaim.js
Normal file
582
backend/src/models/AnimalClaim.js
Normal file
@@ -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;
|
||||
499
backend/src/models/Payment.js
Normal file
499
backend/src/models/Payment.js
Normal file
@@ -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;
|
||||
319
backend/src/models/TravelRegistration.js
Normal file
319
backend/src/models/TravelRegistration.js
Normal file
@@ -0,0 +1,319 @@
|
||||
const db = require('../config/database');
|
||||
|
||||
/**
|
||||
* 旅行报名数据模型
|
||||
* 处理旅行活动报名相关的数据库操作
|
||||
*/
|
||||
class TravelRegistration {
|
||||
/**
|
||||
* 创建报名记录
|
||||
* @param {Object} registrationData - 报名数据
|
||||
* @returns {Promise<Object>} 创建的报名记录
|
||||
*/
|
||||
static async create(registrationData) {
|
||||
const {
|
||||
travel_plan_id,
|
||||
user_id,
|
||||
message,
|
||||
emergency_contact,
|
||||
emergency_phone
|
||||
} = registrationData;
|
||||
|
||||
const query = `
|
||||
INSERT INTO travel_registrations
|
||||
(travel_plan_id, user_id, message, emergency_contact, emergency_phone, status, applied_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', NOW())
|
||||
`;
|
||||
|
||||
const [result] = await db.execute(query, [
|
||||
travel_plan_id,
|
||||
user_id,
|
||||
message || null,
|
||||
emergency_contact || null,
|
||||
emergency_phone || null
|
||||
]);
|
||||
|
||||
return this.findById(result.insertId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查找报名记录
|
||||
* @param {number} id - 报名记录ID
|
||||
* @returns {Promise<Object|null>} 报名记录
|
||||
*/
|
||||
static async findById(id) {
|
||||
const query = `
|
||||
SELECT
|
||||
tr.*,
|
||||
u.username,
|
||||
u.real_name,
|
||||
u.avatar_url,
|
||||
tp.title as travel_title,
|
||||
tp.destination,
|
||||
tp.start_date,
|
||||
tp.end_date
|
||||
FROM travel_registrations tr
|
||||
LEFT JOIN users u ON tr.user_id = u.id
|
||||
LEFT JOIN travel_plans tp ON tr.travel_plan_id = tp.id
|
||||
WHERE tr.id = ?
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已报名某个旅行活动
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} travelPlanId - 旅行活动ID
|
||||
* @returns {Promise<Object|null>} 报名记录
|
||||
*/
|
||||
static async findByUserAndTravel(userId, travelPlanId) {
|
||||
const query = `
|
||||
SELECT * FROM travel_registrations
|
||||
WHERE user_id = ? AND travel_plan_id = ? AND status != 'cancelled'
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [userId, travelPlanId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的报名记录列表
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Promise<Object>} 报名记录列表和分页信息
|
||||
*/
|
||||
static async findByUser(userId, options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
status
|
||||
} = options;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
let whereClause = 'WHERE tr.user_id = ?';
|
||||
const params = [userId];
|
||||
|
||||
if (status) {
|
||||
whereClause += ' AND tr.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM travel_registrations tr
|
||||
${whereClause}
|
||||
`;
|
||||
const [countResult] = await db.execute(countQuery, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 获取数据
|
||||
const query = `
|
||||
SELECT
|
||||
tr.*,
|
||||
tp.title as travel_title,
|
||||
tp.destination,
|
||||
tp.start_date,
|
||||
tp.end_date,
|
||||
tp.max_participants,
|
||||
tp.current_participants
|
||||
FROM travel_registrations tr
|
||||
LEFT JOIN travel_plans tp ON tr.travel_plan_id = tp.id
|
||||
${whereClause}
|
||||
ORDER BY tr.applied_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
params.push(pageSize, offset);
|
||||
const [rows] = await db.execute(query, params);
|
||||
|
||||
return {
|
||||
registrations: rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旅行活动的报名记录列表
|
||||
* @param {number} travelPlanId - 旅行活动ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Promise<Object>} 报名记录列表和分页信息
|
||||
*/
|
||||
static async findByTravelPlan(travelPlanId, options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
status
|
||||
} = options;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
let whereClause = 'WHERE tr.travel_plan_id = ?';
|
||||
const params = [travelPlanId];
|
||||
|
||||
if (status) {
|
||||
whereClause += ' AND tr.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM travel_registrations tr
|
||||
${whereClause}
|
||||
`;
|
||||
const [countResult] = await db.execute(countQuery, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 获取数据
|
||||
const query = `
|
||||
SELECT
|
||||
tr.*,
|
||||
u.username,
|
||||
u.real_name,
|
||||
u.avatar_url,
|
||||
u.phone,
|
||||
u.email
|
||||
FROM travel_registrations tr
|
||||
LEFT JOIN users u ON tr.user_id = u.id
|
||||
${whereClause}
|
||||
ORDER BY tr.applied_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
params.push(pageSize, offset);
|
||||
const [rows] = await db.execute(query, params);
|
||||
|
||||
return {
|
||||
registrations: rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新报名状态
|
||||
* @param {number} id - 报名记录ID
|
||||
* @param {string} status - 新状态
|
||||
* @param {string} rejectReason - 拒绝原因(可选)
|
||||
* @returns {Promise<Object>} 更新后的报名记录
|
||||
*/
|
||||
static async updateStatus(id, status, rejectReason = null) {
|
||||
const query = `
|
||||
UPDATE travel_registrations
|
||||
SET status = ?, reject_reason = ?, responded_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await db.execute(query, [status, rejectReason, id]);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消报名
|
||||
* @param {number} id - 报名记录ID
|
||||
* @returns {Promise<Object>} 更新后的报名记录
|
||||
*/
|
||||
static async cancel(id) {
|
||||
return this.updateStatus(id, 'cancelled');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取报名统计信息
|
||||
* @param {number} travelPlanId - 旅行活动ID
|
||||
* @returns {Promise<Object>} 统计信息
|
||||
*/
|
||||
static async getStats(travelPlanId) {
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(*) as total_applications,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
|
||||
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count,
|
||||
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count,
|
||||
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_count
|
||||
FROM travel_registrations
|
||||
WHERE travel_plan_id = ?
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [travelPlanId]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有权限查看旅行活动的报名列表
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} travelPlanId - 旅行活动ID
|
||||
* @returns {Promise<boolean>} 是否有权限
|
||||
*/
|
||||
static async canViewRegistrations(userId, travelPlanId) {
|
||||
const query = `
|
||||
SELECT id FROM travel_plans
|
||||
WHERE id = ? AND created_by = ?
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [travelPlanId, userId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有权限审核报名
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} registrationId - 报名记录ID
|
||||
* @returns {Promise<boolean>} 是否有权限
|
||||
*/
|
||||
static async canReviewRegistration(userId, registrationId) {
|
||||
const query = `
|
||||
SELECT tr.id
|
||||
FROM travel_registrations tr
|
||||
JOIN travel_plans tp ON tr.travel_plan_id = tp.id
|
||||
WHERE tr.id = ? AND tp.created_by = ?
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [registrationId, userId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旅行活动的已通过报名数量
|
||||
* @param {number} travelPlanId - 旅行活动ID
|
||||
* @returns {Promise<number>} 已通过报名数量
|
||||
*/
|
||||
static async getApprovedCount(travelPlanId) {
|
||||
const query = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM travel_registrations
|
||||
WHERE travel_plan_id = ? AND status = 'approved'
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [travelPlanId]);
|
||||
return rows[0].count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据清理方法 - 移除敏感信息
|
||||
* @param {Object} registration - 报名记录
|
||||
* @returns {Object} 清理后的报名记录
|
||||
*/
|
||||
static sanitize(registration) {
|
||||
if (!registration) return null;
|
||||
|
||||
const sanitized = { ...registration };
|
||||
|
||||
// 移除敏感信息
|
||||
delete sanitized.emergency_phone;
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TravelRegistration;
|
||||
@@ -64,7 +64,7 @@ class UserMySQL {
|
||||
|
||||
// 更新用户信息
|
||||
static async update(id, updates) {
|
||||
const allowedFields = ['nickname', 'avatar', 'gender', 'birthday', 'phone', 'email'];
|
||||
const allowedFields = ['real_name', 'avatar_url', 'email', 'phone', 'user_type'];
|
||||
const setClauses = [];
|
||||
const params = [];
|
||||
|
||||
@@ -79,10 +79,9 @@ class UserMySQL {
|
||||
return false;
|
||||
}
|
||||
|
||||
setClauses.push('updated_at = NOW()');
|
||||
params.push(id);
|
||||
|
||||
const sql = `UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`;
|
||||
const sql = `UPDATE users SET ${setClauses.join(', ')}, updated_at = NOW() WHERE id = ?`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
@@ -96,70 +95,163 @@ class UserMySQL {
|
||||
|
||||
// 更新最后登录时间
|
||||
static async updateLastLogin(id) {
|
||||
const sql = 'UPDATE users SET updated_at = NOW() WHERE id = ?';
|
||||
const result = await query(sql, [id]);
|
||||
return result.affectedRows > 0;
|
||||
const sql = 'UPDATE users SET last_login_at = NOW() WHERE id = ?';
|
||||
await query(sql, [id]);
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
// 检查用户名是否存在
|
||||
static async isUsernameExists(username, excludeId = null) {
|
||||
let sql = 'SELECT COUNT(*) as count FROM users WHERE username = ?';
|
||||
const params = [username];
|
||||
|
||||
|
||||
if (excludeId) {
|
||||
sql += ' AND id != ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
|
||||
|
||||
const rows = await query(sql, params);
|
||||
return rows[0].count > 0;
|
||||
}
|
||||
|
||||
// 检查用户状态是否活跃
|
||||
// 检查用户是否激活
|
||||
static isActive(user) {
|
||||
return user.status === 'active';
|
||||
return user && user.status === 'active';
|
||||
}
|
||||
|
||||
// 执行原始查询(用于复杂查询)
|
||||
// 通用查询方法
|
||||
static async query(sql, params = []) {
|
||||
const { query } = require('../config/database');
|
||||
return await query(sql, params);
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
// 检查邮箱是否存在
|
||||
static async isEmailExists(email, excludeId = null) {
|
||||
if (!email) return false;
|
||||
|
||||
let sql = 'SELECT COUNT(*) as count FROM users WHERE email = ?';
|
||||
const params = [email];
|
||||
|
||||
|
||||
if (excludeId) {
|
||||
sql += ' AND id != ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
|
||||
|
||||
const rows = await query(sql, params);
|
||||
return rows[0].count > 0;
|
||||
}
|
||||
|
||||
// 检查手机号是否已存在
|
||||
// 检查手机号是否存在
|
||||
static async isPhoneExists(phone, excludeId = null) {
|
||||
if (!phone) return false;
|
||||
|
||||
let sql = 'SELECT COUNT(*) as count FROM users WHERE phone = ?';
|
||||
const params = [phone];
|
||||
|
||||
|
||||
if (excludeId) {
|
||||
sql += ' AND id != ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
|
||||
|
||||
const rows = await query(sql, params);
|
||||
return rows[0].count > 0;
|
||||
}
|
||||
|
||||
// 安全返回用户信息(去除敏感信息)
|
||||
// 清理用户数据(移除敏感信息)
|
||||
static sanitize(user) {
|
||||
if (!user) return null;
|
||||
|
||||
const { password_hash, ...safeUser } = user;
|
||||
return safeUser;
|
||||
const { password_hash, ...sanitizedUser } = user;
|
||||
return sanitizedUser;
|
||||
}
|
||||
|
||||
// 保存邮箱验证码
|
||||
static async saveVerificationCode(email, code, expiresAt) {
|
||||
const sql = `
|
||||
INSERT INTO email_verifications (email, code, expires_at, created_at)
|
||||
VALUES (?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
code = VALUES(code),
|
||||
expires_at = VALUES(expires_at),
|
||||
created_at = NOW()
|
||||
`;
|
||||
return await query(sql, [email, code, expiresAt]);
|
||||
}
|
||||
|
||||
// 验证邮箱验证码
|
||||
static async verifyEmailCode(email, code) {
|
||||
const sql = `
|
||||
SELECT * FROM email_verifications
|
||||
WHERE email = ? AND code = ? AND expires_at > NOW()
|
||||
`;
|
||||
const rows = await query(sql, [email, code]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// 删除邮箱验证码
|
||||
static async deleteVerificationCode(email, code) {
|
||||
const sql = 'DELETE FROM email_verifications WHERE email = ? AND code = ?';
|
||||
return await query(sql, [email, code]);
|
||||
}
|
||||
|
||||
// 保存密码重置token
|
||||
static async savePasswordResetToken(userId, token, expiresAt) {
|
||||
const sql = `
|
||||
INSERT INTO password_resets (user_id, token, expires_at, created_at)
|
||||
VALUES (?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
token = VALUES(token),
|
||||
expires_at = VALUES(expires_at),
|
||||
created_at = NOW()
|
||||
`;
|
||||
return await query(sql, [userId, token, expiresAt]);
|
||||
}
|
||||
|
||||
// 查找密码重置token
|
||||
static async findPasswordResetToken(token) {
|
||||
const sql = `
|
||||
SELECT * FROM password_resets
|
||||
WHERE token = ? AND expires_at > NOW()
|
||||
`;
|
||||
const rows = await query(sql, [token]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// 删除密码重置token
|
||||
static async deletePasswordResetToken(token) {
|
||||
const sql = 'DELETE FROM password_resets WHERE token = ?';
|
||||
return await query(sql, [token]);
|
||||
}
|
||||
|
||||
// 记录登录失败次数
|
||||
static async recordLoginFailure(identifier) {
|
||||
const sql = `
|
||||
INSERT INTO login_attempts (identifier, attempts, last_attempt, created_at)
|
||||
VALUES (?, 1, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
attempts = attempts + 1,
|
||||
last_attempt = NOW()
|
||||
`;
|
||||
return await query(sql, [identifier]);
|
||||
}
|
||||
|
||||
// 获取登录失败次数
|
||||
static async getLoginAttempts(identifier) {
|
||||
const sql = `
|
||||
SELECT attempts, last_attempt FROM login_attempts
|
||||
WHERE identifier = ? AND last_attempt > DATE_SUB(NOW(), INTERVAL 1 HOUR)
|
||||
`;
|
||||
const rows = await query(sql, [identifier]);
|
||||
return rows[0] || { attempts: 0 };
|
||||
}
|
||||
|
||||
// 清除登录失败记录
|
||||
static async clearLoginAttempts(identifier) {
|
||||
const sql = 'DELETE FROM login_attempts WHERE identifier = ?';
|
||||
return await query(sql, [identifier]);
|
||||
}
|
||||
|
||||
// 检查账户是否被锁定
|
||||
static async isAccountLocked(identifier) {
|
||||
const attempts = await this.getLoginAttempts(identifier);
|
||||
return attempts.attempts >= 5; // 5次失败后锁定
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,12 @@ const adminController = require('../controllers/admin');
|
||||
const systemStatsController = require('../controllers/admin/systemStats');
|
||||
const { authenticateAdmin } = require('../middleware/auth');
|
||||
|
||||
// 引入子路由
|
||||
const userManagementRoutes = require('./admin/userManagement');
|
||||
const dataStatisticsRoutes = require('./admin/dataStatistics');
|
||||
const animalManagementRoutes = require('./admin/animalManagement');
|
||||
const fileManagementRoutes = require('./admin/fileManagement');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
@@ -683,4 +689,10 @@ router.get('/system/order-stats', authenticateAdmin, systemStatsController.getOr
|
||||
*/
|
||||
router.get('/system/info', authenticateAdmin, systemStatsController.getSystemInfo);
|
||||
|
||||
// 注册子路由
|
||||
router.use('/users', userManagementRoutes);
|
||||
router.use('/statistics', dataStatisticsRoutes);
|
||||
router.use('/animals', animalManagementRoutes);
|
||||
router.use('/files', fileManagementRoutes);
|
||||
|
||||
module.exports = router;
|
||||
611
backend/src/routes/admin/animalManagement.js
Normal file
611
backend/src/routes/admin/animalManagement.js
Normal file
@@ -0,0 +1,611 @@
|
||||
const express = require('express');
|
||||
const { body, query, param } = require('express-validator');
|
||||
const AnimalManagementController = require('../../controllers/admin/animalManagement');
|
||||
const { requireRole } = require('../../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Admin Animal Management
|
||||
* description: 管理员动物管理相关接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* AnimalDetail:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* name:
|
||||
* type: string
|
||||
* description: 动物名称
|
||||
* species:
|
||||
* type: string
|
||||
* description: 动物物种
|
||||
* breed:
|
||||
* type: string
|
||||
* description: 品种
|
||||
* age:
|
||||
* type: integer
|
||||
* description: 年龄(月)
|
||||
* gender:
|
||||
* type: string
|
||||
* enum: [male, female]
|
||||
* description: 性别
|
||||
* price:
|
||||
* type: number
|
||||
* description: 认领价格
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [available, claimed, unavailable]
|
||||
* description: 状态
|
||||
* description:
|
||||
* type: string
|
||||
* description: 动物描述
|
||||
* images:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: 动物图片
|
||||
* merchant_id:
|
||||
* type: integer
|
||||
* description: 商家ID
|
||||
* merchant_name:
|
||||
* type: string
|
||||
* description: 商家名称
|
||||
* claim_count:
|
||||
* type: integer
|
||||
* description: 被认领次数
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 更新时间
|
||||
* AnimalStatistics:
|
||||
* type: object
|
||||
* properties:
|
||||
* totalStats:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_animals:
|
||||
* type: integer
|
||||
* description: 动物总数
|
||||
* available_animals:
|
||||
* type: integer
|
||||
* description: 可认领动物数
|
||||
* claimed_animals:
|
||||
* type: integer
|
||||
* description: 已认领动物数
|
||||
* total_claims:
|
||||
* type: integer
|
||||
* description: 总认领次数
|
||||
* avg_price:
|
||||
* type: number
|
||||
* description: 平均价格
|
||||
* speciesStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* species:
|
||||
* type: string
|
||||
* count:
|
||||
* type: integer
|
||||
* avg_price:
|
||||
* type: number
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals:
|
||||
* get:
|
||||
* summary: 获取动物列表
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 搜索关键词
|
||||
* - in: query
|
||||
* name: species
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 动物物种
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [available, claimed, unavailable]
|
||||
* description: 动物状态
|
||||
* - in: query
|
||||
* name: merchant_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 商家ID
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: sort_by
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [created_at, updated_at, price, claim_count]
|
||||
* default: created_at
|
||||
* description: 排序字段
|
||||
* - in: query
|
||||
* name: sort_order
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [asc, desc]
|
||||
* default: desc
|
||||
* description: 排序方向
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* animals:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalDetail'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('keyword').optional().isString(),
|
||||
query('species').optional().isString(),
|
||||
query('status').optional().isIn(['available', 'claimed', 'unavailable']),
|
||||
query('merchant_id').optional().isInt(),
|
||||
query('start_date').optional().isDate(),
|
||||
query('end_date').optional().isDate(),
|
||||
query('sort_by').optional().isIn(['created_at', 'updated_at', 'price', 'claim_count']),
|
||||
query('sort_order').optional().isIn(['asc', 'desc'])
|
||||
],
|
||||
AnimalManagementController.getAnimalList
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/{animal_id}:
|
||||
* get:
|
||||
* summary: 获取动物详情
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: animal_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* animal:
|
||||
* $ref: '#/components/schemas/AnimalDetail'
|
||||
* claimStats:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_claims:
|
||||
* type: integer
|
||||
* pending_claims:
|
||||
* type: integer
|
||||
* approved_claims:
|
||||
* type: integer
|
||||
* rejected_claims:
|
||||
* type: integer
|
||||
* recentClaims:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 动物不存在
|
||||
*/
|
||||
router.get('/:animal_id',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('animal_id').isInt({ min: 1 })
|
||||
],
|
||||
AnimalManagementController.getAnimalDetail
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/{animal_id}/status:
|
||||
* put:
|
||||
* summary: 更新动物状态
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: animal_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [available, claimed, unavailable]
|
||||
* description: 新状态
|
||||
* reason:
|
||||
* type: string
|
||||
* description: 状态变更原因
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 更新成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 动物不存在
|
||||
*/
|
||||
router.put('/:animal_id/status',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('animal_id').isInt({ min: 1 }),
|
||||
body('status').isIn(['available', 'claimed', 'unavailable']),
|
||||
body('reason').optional().isString().isLength({ max: 500 })
|
||||
],
|
||||
AnimalManagementController.updateAnimalStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/batch/status:
|
||||
* put:
|
||||
* summary: 批量更新动物状态
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - animal_ids
|
||||
* - status
|
||||
* properties:
|
||||
* animal_ids:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* description: 动物ID列表
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [available, claimed, unavailable]
|
||||
* description: 新状态
|
||||
* reason:
|
||||
* type: string
|
||||
* description: 状态变更原因
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 批量更新成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* updated_count:
|
||||
* type: integer
|
||||
* description: 更新的动物数量
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.put('/batch/status',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
body('animal_ids').isArray({ min: 1 }),
|
||||
body('animal_ids.*').isInt({ min: 1 }),
|
||||
body('status').isIn(['available', 'claimed', 'unavailable']),
|
||||
body('reason').optional().isString().isLength({ max: 500 })
|
||||
],
|
||||
AnimalManagementController.batchUpdateAnimalStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/statistics:
|
||||
* get:
|
||||
* summary: 获取动物统计信息
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/AnimalStatistics'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/statistics',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
AnimalManagementController.getAnimalStatistics
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/export:
|
||||
* get:
|
||||
* summary: 导出动物数据
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: format
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [csv, json]
|
||||
* default: csv
|
||||
* description: 导出格式
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 搜索关键词
|
||||
* - in: query
|
||||
* name: species
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 动物物种
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [available, claimed, unavailable]
|
||||
* description: 动物状态
|
||||
* - in: query
|
||||
* name: merchant_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 商家ID
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 导出成功
|
||||
* content:
|
||||
* text/csv:
|
||||
* schema:
|
||||
* type: string
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* animals:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalDetail'
|
||||
* export_time:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* total_count:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/export',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('format').optional().isIn(['csv', 'json']),
|
||||
query('keyword').optional().isString(),
|
||||
query('species').optional().isString(),
|
||||
query('status').optional().isIn(['available', 'claimed', 'unavailable']),
|
||||
query('merchant_id').optional().isInt(),
|
||||
query('start_date').optional().isDate(),
|
||||
query('end_date').optional().isDate()
|
||||
],
|
||||
AnimalManagementController.exportAnimalData
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/{animal_id}/claims:
|
||||
* get:
|
||||
* summary: 获取动物认领记录
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: animal_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 认领状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* claims:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/:animal_id/claims',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('animal_id').isInt({ min: 1 }),
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('status').optional().isIn(['pending', 'approved', 'rejected', 'cancelled'])
|
||||
],
|
||||
AnimalManagementController.getAnimalClaimRecords
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
522
backend/src/routes/admin/dataStatistics.js
Normal file
522
backend/src/routes/admin/dataStatistics.js
Normal file
@@ -0,0 +1,522 @@
|
||||
const express = require('express');
|
||||
const { query } = require('express-validator');
|
||||
const DataStatisticsController = require('../../controllers/admin/dataStatistics');
|
||||
const { requireRole } = require('../../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Admin Data Statistics
|
||||
* description: 管理员数据统计相关接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* SystemOverview:
|
||||
* type: object
|
||||
* properties:
|
||||
* users:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_users:
|
||||
* type: integer
|
||||
* description: 用户总数
|
||||
* active_users:
|
||||
* type: integer
|
||||
* description: 活跃用户数
|
||||
* new_users_today:
|
||||
* type: integer
|
||||
* description: 今日新增用户
|
||||
* new_users_week:
|
||||
* type: integer
|
||||
* description: 本周新增用户
|
||||
* travels:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_travels:
|
||||
* type: integer
|
||||
* description: 旅行总数
|
||||
* published_travels:
|
||||
* type: integer
|
||||
* description: 已发布旅行
|
||||
* new_travels_today:
|
||||
* type: integer
|
||||
* description: 今日新增旅行
|
||||
* animals:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_animals:
|
||||
* type: integer
|
||||
* description: 动物总数
|
||||
* available_animals:
|
||||
* type: integer
|
||||
* description: 可认领动物
|
||||
* claimed_animals:
|
||||
* type: integer
|
||||
* description: 已认领动物
|
||||
* orders:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_orders:
|
||||
* type: integer
|
||||
* description: 订单总数
|
||||
* completed_orders:
|
||||
* type: integer
|
||||
* description: 已完成订单
|
||||
* total_revenue:
|
||||
* type: number
|
||||
* description: 总收入
|
||||
* TrendData:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 日期
|
||||
* new_users:
|
||||
* type: integer
|
||||
* description: 新增用户数
|
||||
* cumulative_users:
|
||||
* type: integer
|
||||
* description: 累计用户数
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/overview:
|
||||
* get:
|
||||
* summary: 获取系统概览统计
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* $ref: '#/components/schemas/SystemOverview'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/overview',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
DataStatisticsController.getSystemOverview
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/user-growth:
|
||||
* get:
|
||||
* summary: 获取用户增长趋势
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: period
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [7d, 30d, 90d, 365d]
|
||||
* default: 30d
|
||||
* description: 统计周期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* period:
|
||||
* type: string
|
||||
* trendData:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/TrendData'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/user-growth',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('period').optional().isIn(['7d', '30d', '90d', '365d'])
|
||||
],
|
||||
DataStatisticsController.getUserGrowthTrend
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/business:
|
||||
* get:
|
||||
* summary: 获取业务数据统计
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: period
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [7d, 30d, 90d]
|
||||
* default: 30d
|
||||
* description: 统计周期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* period:
|
||||
* type: string
|
||||
* travelStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* new_travels:
|
||||
* type: integer
|
||||
* published_travels:
|
||||
* type: integer
|
||||
* matched_travels:
|
||||
* type: integer
|
||||
* claimStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* new_claims:
|
||||
* type: integer
|
||||
* approved_claims:
|
||||
* type: integer
|
||||
* rejected_claims:
|
||||
* type: integer
|
||||
* orderStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* new_orders:
|
||||
* type: integer
|
||||
* completed_orders:
|
||||
* type: integer
|
||||
* daily_revenue:
|
||||
* type: number
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/business',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('period').optional().isIn(['7d', '30d', '90d'])
|
||||
],
|
||||
DataStatisticsController.getBusinessStatistics
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/geographic:
|
||||
* get:
|
||||
* summary: 获取地域分布统计
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* userDistribution:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* province:
|
||||
* type: string
|
||||
* city:
|
||||
* type: string
|
||||
* user_count:
|
||||
* type: integer
|
||||
* provinceStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* province:
|
||||
* type: string
|
||||
* user_count:
|
||||
* type: integer
|
||||
* farmer_count:
|
||||
* type: integer
|
||||
* merchant_count:
|
||||
* type: integer
|
||||
* destinationStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* destination:
|
||||
* type: string
|
||||
* travel_count:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/geographic',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
DataStatisticsController.getGeographicDistribution
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/user-behavior:
|
||||
* get:
|
||||
* summary: 获取用户行为分析
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* activityStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* activity_level:
|
||||
* type: string
|
||||
* user_count:
|
||||
* type: integer
|
||||
* levelDistribution:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* level:
|
||||
* type: string
|
||||
* user_count:
|
||||
* type: integer
|
||||
* avg_points:
|
||||
* type: number
|
||||
* avg_travel_count:
|
||||
* type: number
|
||||
* avg_claim_count:
|
||||
* type: number
|
||||
* behaviorStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* behavior_type:
|
||||
* type: string
|
||||
* user_count:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/user-behavior',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
DataStatisticsController.getUserBehaviorAnalysis
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/revenue:
|
||||
* get:
|
||||
* summary: 获取收入统计
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: period
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [7d, 30d, 90d, 365d]
|
||||
* default: 30d
|
||||
* description: 统计周期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* period:
|
||||
* type: string
|
||||
* revenueTrend:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* daily_revenue:
|
||||
* type: number
|
||||
* completed_orders:
|
||||
* type: integer
|
||||
* total_orders:
|
||||
* type: integer
|
||||
* revenueSource:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* order_type:
|
||||
* type: string
|
||||
* order_count:
|
||||
* type: integer
|
||||
* total_revenue:
|
||||
* type: number
|
||||
* avg_order_value:
|
||||
* type: number
|
||||
* paymentMethodStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* payment_method:
|
||||
* type: string
|
||||
* order_count:
|
||||
* type: integer
|
||||
* total_amount:
|
||||
* type: number
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/revenue',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('period').optional().isIn(['7d', '30d', '90d', '365d'])
|
||||
],
|
||||
DataStatisticsController.getRevenueStatistics
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/export:
|
||||
* get:
|
||||
* summary: 导出统计报告
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: reportType
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [overview, users, revenue]
|
||||
* default: overview
|
||||
* description: 报告类型
|
||||
* - in: query
|
||||
* name: period
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [7d, 30d, 90d]
|
||||
* default: 30d
|
||||
* description: 统计周期
|
||||
* - in: query
|
||||
* name: format
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [csv, json]
|
||||
* default: csv
|
||||
* description: 导出格式
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 导出成功
|
||||
* content:
|
||||
* text/csv:
|
||||
* schema:
|
||||
* type: string
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/export',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('reportType').optional().isIn(['overview', 'users', 'revenue']),
|
||||
query('period').optional().isIn(['7d', '30d', '90d']),
|
||||
query('format').optional().isIn(['csv', 'json'])
|
||||
],
|
||||
DataStatisticsController.exportStatisticsReport
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
601
backend/src/routes/admin/fileManagement.js
Normal file
601
backend/src/routes/admin/fileManagement.js
Normal file
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* 管理员文件管理路由
|
||||
* 定义文件上传、管理、统计等API接口
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getFileList,
|
||||
getFileDetail,
|
||||
deleteFileById,
|
||||
batchDeleteFiles,
|
||||
getFileStatistics,
|
||||
cleanupUnusedFiles,
|
||||
uploadFile
|
||||
} = require('../../controllers/admin/fileManagement');
|
||||
const { uploadMiddlewares, imageProcessors } = require('../../middleware/upload');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* FileInfo:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: 文件ID(Base64编码的文件路径)
|
||||
* filename:
|
||||
* type: string
|
||||
* description: 文件名
|
||||
* originalName:
|
||||
* type: string
|
||||
* description: 原始文件名
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [avatar, animal, travel, document]
|
||||
* description: 文件类型
|
||||
* size:
|
||||
* type: integer
|
||||
* description: 文件大小(字节)
|
||||
* mimetype:
|
||||
* type: string
|
||||
* description: MIME类型
|
||||
* isImage:
|
||||
* type: boolean
|
||||
* description: 是否为图片
|
||||
* url:
|
||||
* type: string
|
||||
* description: 文件访问URL
|
||||
* thumbnailUrl:
|
||||
* type: string
|
||||
* description: 缩略图URL(仅图片)
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
* modified_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 修改时间
|
||||
*
|
||||
* FileStatistics:
|
||||
* type: object
|
||||
* properties:
|
||||
* totalFiles:
|
||||
* type: integer
|
||||
* description: 文件总数
|
||||
* totalSize:
|
||||
* type: integer
|
||||
* description: 总大小(字节)
|
||||
* typeStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* description: 文件类型
|
||||
* count:
|
||||
* type: integer
|
||||
* description: 文件数量
|
||||
* size:
|
||||
* type: integer
|
||||
* description: 总大小
|
||||
* avgSize:
|
||||
* type: integer
|
||||
* description: 平均大小
|
||||
* sizeDistribution:
|
||||
* type: object
|
||||
* properties:
|
||||
* small:
|
||||
* type: integer
|
||||
* description: 小文件数量(<1MB)
|
||||
* medium:
|
||||
* type: integer
|
||||
* description: 中等文件数量(1-5MB)
|
||||
* large:
|
||||
* type: integer
|
||||
* description: 大文件数量(>5MB)
|
||||
* formatStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* format:
|
||||
* type: string
|
||||
* description: 文件格式
|
||||
* count:
|
||||
* type: integer
|
||||
* description: 数量
|
||||
* size:
|
||||
* type: integer
|
||||
* description: 总大小
|
||||
* percentage:
|
||||
* type: string
|
||||
* description: 占比百分比
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files:
|
||||
* get:
|
||||
* summary: 获取文件列表
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: type
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [all, avatar, animal, travel, document]
|
||||
* default: all
|
||||
* description: 文件类型
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 搜索关键词
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: sort_by
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [created_at, modified_at, size, filename]
|
||||
* default: created_at
|
||||
* description: 排序字段
|
||||
* - in: query
|
||||
* name: sort_order
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [asc, desc]
|
||||
* default: desc
|
||||
* description: 排序方向
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
*/
|
||||
router.get('/', getFileList);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/{file_id}:
|
||||
* get:
|
||||
* summary: 获取文件详情
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: file_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 文件ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* file:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
* 404:
|
||||
* description: 文件不存在
|
||||
*/
|
||||
router.get('/:file_id', getFileDetail);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/{file_id}:
|
||||
* delete:
|
||||
* summary: 删除文件
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: file_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 文件ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 删除成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* 404:
|
||||
* description: 文件不存在
|
||||
*/
|
||||
router.delete('/:file_id', deleteFileById);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/batch/delete:
|
||||
* post:
|
||||
* summary: 批量删除文件
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - file_ids
|
||||
* properties:
|
||||
* file_ids:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: 文件ID列表(最多50个)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 批量删除完成
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* file_id:
|
||||
* type: string
|
||||
* filename:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
* failed:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* file_id:
|
||||
* type: string
|
||||
* filename:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
*/
|
||||
router.post('/batch/delete', batchDeleteFiles);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/statistics:
|
||||
* get:
|
||||
* summary: 获取文件统计信息
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/FileStatistics'
|
||||
*/
|
||||
router.get('/statistics', getFileStatistics);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/cleanup:
|
||||
* post:
|
||||
* summary: 清理无用文件
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: dry_run
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: true
|
||||
* description: 是否为试运行(不实际删除文件)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 清理完成
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* scanned:
|
||||
* type: integer
|
||||
* description: 扫描的文件数量
|
||||
* unused:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* filename:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* size:
|
||||
* type: integer
|
||||
* lastModified:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* deleted:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* filename:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* size:
|
||||
* type: integer
|
||||
* errors:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* filename:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* error:
|
||||
* type: string
|
||||
*/
|
||||
router.post('/cleanup', cleanupUnusedFiles);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/upload/avatar:
|
||||
* post:
|
||||
* summary: 上传头像
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* avatar:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: 头像文件(支持jpg、png格式,最大2MB)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 上传成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
*/
|
||||
router.post('/upload/avatar', uploadMiddlewares.avatar, imageProcessors.avatar, uploadFile);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/upload/animal:
|
||||
* post:
|
||||
* summary: 上传动物图片
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* images:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: 动物图片文件(支持jpg、png、gif、webp格式,最大5MB,最多5张)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 上传成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
*/
|
||||
router.post('/upload/animal', uploadMiddlewares.animalImages, imageProcessors.animal, uploadFile);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/upload/travel:
|
||||
* post:
|
||||
* summary: 上传旅行图片
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* images:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: 旅行图片文件(支持jpg、png、gif、webp格式,最大5MB,最多10张)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 上传成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
*/
|
||||
router.post('/upload/travel', uploadMiddlewares.travelImages, imageProcessors.travel, uploadFile);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/upload/document:
|
||||
* post:
|
||||
* summary: 上传文档
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* documents:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: 文档文件(支持pdf、doc、docx、xls、xlsx、txt格式,最大10MB,最多3个)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 上传成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
*/
|
||||
router.post('/upload/document', uploadMiddlewares.documents, uploadFile);
|
||||
|
||||
module.exports = router;
|
||||
504
backend/src/routes/admin/userManagement.js
Normal file
504
backend/src/routes/admin/userManagement.js
Normal file
@@ -0,0 +1,504 @@
|
||||
const express = require('express');
|
||||
const { body, query, param } = require('express-validator');
|
||||
const UserManagementController = require('../../controllers/admin/userManagement');
|
||||
const { requireRole } = require('../../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Admin User Management
|
||||
* description: 管理员用户管理相关接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* UserDetail:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* nickname:
|
||||
* type: string
|
||||
* description: 用户昵称
|
||||
* phone:
|
||||
* type: string
|
||||
* description: 手机号
|
||||
* email:
|
||||
* type: string
|
||||
* description: 邮箱
|
||||
* user_type:
|
||||
* type: string
|
||||
* enum: [farmer, merchant]
|
||||
* description: 用户类型
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [active, inactive, banned]
|
||||
* description: 用户状态
|
||||
* travel_count:
|
||||
* type: integer
|
||||
* description: 旅行次数
|
||||
* animal_claim_count:
|
||||
* type: integer
|
||||
* description: 认领次数
|
||||
* points:
|
||||
* type: integer
|
||||
* description: 积分
|
||||
* level:
|
||||
* type: string
|
||||
* enum: [bronze, silver, gold, platinum]
|
||||
* description: 用户等级
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
* last_login_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 最后登录时间
|
||||
* UserStatistics:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_users:
|
||||
* type: integer
|
||||
* description: 用户总数
|
||||
* active_users:
|
||||
* type: integer
|
||||
* description: 活跃用户数
|
||||
* new_users_today:
|
||||
* type: integer
|
||||
* description: 今日新增用户
|
||||
* new_users_week:
|
||||
* type: integer
|
||||
* description: 本周新增用户
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users:
|
||||
* get:
|
||||
* summary: 获取用户列表
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: pageSize
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 搜索关键词(昵称、手机号、邮箱)
|
||||
* - in: query
|
||||
* name: userType
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [farmer, merchant]
|
||||
* description: 用户类型
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [active, inactive, banned]
|
||||
* description: 用户状态
|
||||
* - in: query
|
||||
* name: startDate
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: endDate
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: sortField
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [created_at, last_login_at, points, travel_count]
|
||||
* default: created_at
|
||||
* description: 排序字段
|
||||
* - in: query
|
||||
* name: sortOrder
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [asc, desc]
|
||||
* default: desc
|
||||
* description: 排序方向
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* users:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/UserDetail'
|
||||
* pagination:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* pageSize:
|
||||
* type: integer
|
||||
* total:
|
||||
* type: integer
|
||||
* totalPages:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('pageSize').optional().isInt({ min: 1, max: 100 }),
|
||||
query('userType').optional().isIn(['farmer', 'merchant']),
|
||||
query('status').optional().isIn(['active', 'inactive', 'banned']),
|
||||
query('sortField').optional().isIn(['created_at', 'last_login_at', 'points', 'travel_count']),
|
||||
query('sortOrder').optional().isIn(['asc', 'desc'])
|
||||
],
|
||||
UserManagementController.getUserList
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users/{userId}:
|
||||
* get:
|
||||
* summary: 获取用户详情
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: userId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* user:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/UserDetail'
|
||||
* - type: object
|
||||
* properties:
|
||||
* interests:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: 用户兴趣
|
||||
* recentTravels:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* description: 最近旅行记录
|
||||
* recentClaims:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* description: 最近认领记录
|
||||
* 404:
|
||||
* description: 用户不存在
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/:userId',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('userId').isInt({ min: 1 })
|
||||
],
|
||||
UserManagementController.getUserDetail
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users/{userId}/status:
|
||||
* put:
|
||||
* summary: 更新用户状态
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: userId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [active, inactive, banned]
|
||||
* description: 新状态
|
||||
* reason:
|
||||
* type: string
|
||||
* description: 操作原因
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 更新成功
|
||||
* 400:
|
||||
* description: 无效的状态值
|
||||
* 404:
|
||||
* description: 用户不存在
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.put('/:userId/status',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('userId').isInt({ min: 1 }),
|
||||
body('status').isIn(['active', 'inactive', 'banned']),
|
||||
body('reason').optional().isString()
|
||||
],
|
||||
UserManagementController.updateUserStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users/batch-status:
|
||||
* put:
|
||||
* summary: 批量更新用户状态
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - userIds
|
||||
* - status
|
||||
* properties:
|
||||
* userIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* description: 用户ID列表
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [active, inactive, banned]
|
||||
* description: 新状态
|
||||
* reason:
|
||||
* type: string
|
||||
* description: 操作原因
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 更新成功
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.put('/batch-status',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
body('userIds').isArray({ min: 1 }),
|
||||
body('userIds.*').isInt({ min: 1 }),
|
||||
body('status').isIn(['active', 'inactive', 'banned']),
|
||||
body('reason').optional().isString()
|
||||
],
|
||||
UserManagementController.batchUpdateUserStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users/statistics:
|
||||
* get:
|
||||
* summary: 获取用户统计信息
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: period
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [7d, 30d, 90d]
|
||||
* default: 30d
|
||||
* description: 统计周期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* basicStats:
|
||||
* $ref: '#/components/schemas/UserStatistics'
|
||||
* levelDistribution:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* level:
|
||||
* type: string
|
||||
* count:
|
||||
* type: integer
|
||||
* trendData:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* new_users:
|
||||
* type: integer
|
||||
* new_farmers:
|
||||
* type: integer
|
||||
* new_merchants:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/statistics',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('period').optional().isIn(['7d', '30d', '90d'])
|
||||
],
|
||||
UserManagementController.getUserStatistics
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users/export:
|
||||
* get:
|
||||
* summary: 导出用户数据
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: format
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [csv, json]
|
||||
* default: csv
|
||||
* description: 导出格式
|
||||
* - in: query
|
||||
* name: userType
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [farmer, merchant]
|
||||
* description: 用户类型筛选
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [active, inactive, banned]
|
||||
* description: 状态筛选
|
||||
* - in: query
|
||||
* name: startDate
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: endDate
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 导出成功
|
||||
* content:
|
||||
* text/csv:
|
||||
* schema:
|
||||
* type: string
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* users:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/UserDetail'
|
||||
* total:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/export',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('format').optional().isIn(['csv', 'json']),
|
||||
query('userType').optional().isIn(['farmer', 'merchant']),
|
||||
query('status').optional().isIn(['active', 'inactive', 'banned'])
|
||||
],
|
||||
UserManagementController.exportUsers
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
656
backend/src/routes/animalClaim.js
Normal file
656
backend/src/routes/animalClaim.js
Normal file
@@ -0,0 +1,656 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const AnimalClaimController = require('../controllers/animalClaim');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* AnimalClaim:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 认领申请ID
|
||||
* claim_no:
|
||||
* type: string
|
||||
* description: 认领订单号
|
||||
* animal_id:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* animal_name:
|
||||
* type: string
|
||||
* description: 动物名称
|
||||
* animal_type:
|
||||
* type: string
|
||||
* description: 动物类型
|
||||
* animal_image:
|
||||
* type: string
|
||||
* description: 动物图片
|
||||
* user_id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* username:
|
||||
* type: string
|
||||
* description: 用户名
|
||||
* user_phone:
|
||||
* type: string
|
||||
* description: 用户手机号
|
||||
* claim_reason:
|
||||
* type: string
|
||||
* description: 认领理由
|
||||
* claim_duration:
|
||||
* type: integer
|
||||
* description: 认领时长(月)
|
||||
* total_amount:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 总金额
|
||||
* contact_info:
|
||||
* type: string
|
||||
* description: 联系方式
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 申请状态
|
||||
* start_date:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 开始日期
|
||||
* end_date:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 结束日期
|
||||
* reviewed_by:
|
||||
* type: integer
|
||||
* description: 审核人ID
|
||||
* reviewer_name:
|
||||
* type: string
|
||||
* description: 审核人姓名
|
||||
* review_remark:
|
||||
* type: string
|
||||
* description: 审核备注
|
||||
* reviewed_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 审核时间
|
||||
* approved_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 通过时间
|
||||
* cancelled_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 取消时间
|
||||
* cancel_reason:
|
||||
* type: string
|
||||
* description: 取消原因
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 更新时间
|
||||
*
|
||||
* ClaimStatistics:
|
||||
* type: object
|
||||
* properties:
|
||||
* basic:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_claims:
|
||||
* type: integer
|
||||
* description: 总申请数
|
||||
* pending_claims:
|
||||
* type: integer
|
||||
* description: 待审核申请数
|
||||
* approved_claims:
|
||||
* type: integer
|
||||
* description: 已通过申请数
|
||||
* rejected_claims:
|
||||
* type: integer
|
||||
* description: 已拒绝申请数
|
||||
* cancelled_claims:
|
||||
* type: integer
|
||||
* description: 已取消申请数
|
||||
* total_amount:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 总金额
|
||||
* avg_duration:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 平均认领时长
|
||||
* by_type:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* description: 动物类型
|
||||
* claim_count:
|
||||
* type: integer
|
||||
* description: 申请数量
|
||||
* approved_count:
|
||||
* type: integer
|
||||
* description: 通过数量
|
||||
* total_amount:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 总金额
|
||||
* by_month:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* month:
|
||||
* type: string
|
||||
* description: 月份
|
||||
* claim_count:
|
||||
* type: integer
|
||||
* description: 申请数量
|
||||
* approved_count:
|
||||
* type: integer
|
||||
* description: 通过数量
|
||||
* total_amount:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 总金额
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims:
|
||||
* post:
|
||||
* summary: 申请认领动物
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - animal_id
|
||||
* - contact_info
|
||||
* properties:
|
||||
* animal_id:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* claim_reason:
|
||||
* type: string
|
||||
* description: 认领理由
|
||||
* claim_duration:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 60
|
||||
* description: 认领时长(月,默认12个月)
|
||||
* contact_info:
|
||||
* type: string
|
||||
* description: 联系方式
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 认领申请提交成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.post('/', authenticateToken, AnimalClaimController.createClaim);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/my:
|
||||
* get:
|
||||
* summary: 获取我的认领申请列表
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 申请状态
|
||||
* - in: query
|
||||
* name: animal_type
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 动物类型
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
*/
|
||||
router.get('/my', authenticateToken, AnimalClaimController.getUserClaims);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/statistics:
|
||||
* get:
|
||||
* summary: 获取认领统计信息
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: animal_type
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 动物类型
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/ClaimStatistics'
|
||||
*/
|
||||
router.get('/statistics', authenticateToken, requireRole(['admin', 'manager']), AnimalClaimController.getClaimStatistics);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/animal/{animal_id}:
|
||||
* get:
|
||||
* summary: 获取动物的认领申请列表
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: animal_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 申请状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
*/
|
||||
router.get('/animal/:animal_id', authenticateToken, requireRole(['admin', 'manager']), AnimalClaimController.getAnimalClaims);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/check-permission/{animal_id}:
|
||||
* get:
|
||||
* summary: 检查认领权限
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: animal_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 检查成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* can_claim:
|
||||
* type: boolean
|
||||
* description: 是否可以认领
|
||||
*/
|
||||
router.get('/check-permission/:animal_id', authenticateToken, AnimalClaimController.checkClaimPermission);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims:
|
||||
* get:
|
||||
* summary: 获取所有认领申请列表(管理员)
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 申请状态
|
||||
* - in: query
|
||||
* name: animal_type
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 动物类型
|
||||
* - in: query
|
||||
* name: user_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 关键词搜索(订单号、动物名称、用户名)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
*/
|
||||
router.get('/', authenticateToken, requireRole(['admin', 'manager']), AnimalClaimController.getAllClaims);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/{id}/cancel:
|
||||
* put:
|
||||
* summary: 取消认领申请
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 认领申请ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 取消成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.put('/:id/cancel', authenticateToken, AnimalClaimController.cancelClaim);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/{id}/review:
|
||||
* put:
|
||||
* summary: 审核认领申请
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 认领申请ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [approved, rejected]
|
||||
* description: 审核状态
|
||||
* review_remark:
|
||||
* type: string
|
||||
* description: 审核备注
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 审核成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.put('/:id/review', authenticateToken, requireRole(['admin', 'manager']), AnimalClaimController.reviewClaim);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/{id}/renew:
|
||||
* post:
|
||||
* summary: 续期认领
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 认领申请ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - duration
|
||||
* - payment_method
|
||||
* properties:
|
||||
* duration:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 60
|
||||
* description: 续期时长(月)
|
||||
* payment_method:
|
||||
* type: string
|
||||
* enum: [wechat, alipay, bank_transfer]
|
||||
* description: 支付方式
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 续期申请成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* renewal:
|
||||
* type: object
|
||||
* description: 续期记录
|
||||
* amount:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 续期金额
|
||||
* message:
|
||||
* type: string
|
||||
* description: 提示信息
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.post('/:id/renew', authenticateToken, AnimalClaimController.renewClaim);
|
||||
|
||||
module.exports = router;
|
||||
@@ -330,6 +330,182 @@ router.put(
|
||||
* 500:
|
||||
* description: 服务器内部错误
|
||||
*/
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/refresh:
|
||||
* post:
|
||||
* summary: 刷新访问令牌
|
||||
* tags: [Auth]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - refreshToken
|
||||
* properties:
|
||||
* refreshToken:
|
||||
* type: string
|
||||
* description: 刷新令牌
|
||||
* example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 令牌刷新成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* description: 新的访问令牌
|
||||
* message:
|
||||
* type: string
|
||||
* example: Token刷新成功
|
||||
* 400:
|
||||
* description: 刷新令牌不能为空
|
||||
* 401:
|
||||
* description: 无效或过期的刷新令牌
|
||||
*/
|
||||
router.post('/refresh', authController.refreshToken);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/send-verification:
|
||||
* post:
|
||||
* summary: 发送邮箱验证码
|
||||
* tags: [Auth]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: 邮箱地址
|
||||
* example: user@example.com
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 验证码发送成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: 验证码已发送到您的邮箱
|
||||
* 400:
|
||||
* description: 邮箱不能为空或格式不正确
|
||||
*/
|
||||
router.post('/send-verification', authController.sendEmailVerification);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/forgot-password:
|
||||
* post:
|
||||
* summary: 忘记密码
|
||||
* tags: [Auth]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: 注册邮箱
|
||||
* example: user@example.com
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 重置链接发送成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: 如果该邮箱已注册,重置密码链接已发送到您的邮箱
|
||||
* 400:
|
||||
* description: 邮箱不能为空
|
||||
*/
|
||||
router.post('/forgot-password', authController.forgotPassword);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/reset-password:
|
||||
* post:
|
||||
* summary: 重置密码
|
||||
* tags: [Auth]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - token
|
||||
* - newPassword
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* description: 重置令牌
|
||||
* example: abc123def456...
|
||||
* newPassword:
|
||||
* type: string
|
||||
* description: 新密码
|
||||
* example: newpassword123
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 密码重置成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: 密码重置成功
|
||||
* 400:
|
||||
* description: 重置令牌无效或新密码格式错误
|
||||
*/
|
||||
router.post('/reset-password', authController.resetPassword);
|
||||
|
||||
router.post('/admin/login', authController.adminLogin);
|
||||
|
||||
/**
|
||||
|
||||
561
backend/src/routes/payment.js
Normal file
561
backend/src/routes/payment.js
Normal file
@@ -0,0 +1,561 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const PaymentController = require('../controllers/payment');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
const { body, param } = require('express-validator');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* Payment:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 支付订单ID
|
||||
* payment_no:
|
||||
* type: string
|
||||
* description: 支付订单号
|
||||
* order_id:
|
||||
* type: integer
|
||||
* description: 关联订单ID
|
||||
* user_id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 支付金额
|
||||
* paid_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 实际支付金额
|
||||
* payment_method:
|
||||
* type: string
|
||||
* enum: [wechat, alipay, balance]
|
||||
* description: 支付方式
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [pending, paid, failed, refunded, cancelled]
|
||||
* description: 支付状态
|
||||
* transaction_id:
|
||||
* type: string
|
||||
* description: 第三方交易号
|
||||
* paid_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 支付时间
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 更新时间
|
||||
*
|
||||
* Refund:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 退款ID
|
||||
* refund_no:
|
||||
* type: string
|
||||
* description: 退款订单号
|
||||
* payment_id:
|
||||
* type: integer
|
||||
* description: 支付订单ID
|
||||
* user_id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* refund_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 退款金额
|
||||
* refund_reason:
|
||||
* type: string
|
||||
* description: 退款原因
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, completed]
|
||||
* description: 退款状态
|
||||
* processed_by:
|
||||
* type: integer
|
||||
* description: 处理人ID
|
||||
* process_remark:
|
||||
* type: string
|
||||
* description: 处理备注
|
||||
* processed_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 处理时间
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
*
|
||||
* PaymentStatistics:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 总支付金额
|
||||
* total_count:
|
||||
* type: integer
|
||||
* description: 总支付笔数
|
||||
* success_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 成功支付金额
|
||||
* success_count:
|
||||
* type: integer
|
||||
* description: 成功支付笔数
|
||||
* refund_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 退款金额
|
||||
* refund_count:
|
||||
* type: integer
|
||||
* description: 退款笔数
|
||||
* method_stats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* payment_method:
|
||||
* type: string
|
||||
* description: 支付方式
|
||||
* amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 金额
|
||||
* count:
|
||||
* type: integer
|
||||
* description: 笔数
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments:
|
||||
* post:
|
||||
* summary: 创建支付订单
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - order_id
|
||||
* - amount
|
||||
* - payment_method
|
||||
* properties:
|
||||
* order_id:
|
||||
* type: integer
|
||||
* description: 订单ID
|
||||
* amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 支付金额
|
||||
* payment_method:
|
||||
* type: string
|
||||
* enum: [wechat, alipay, balance]
|
||||
* description: 支付方式
|
||||
* return_url:
|
||||
* type: string
|
||||
* description: 支付成功回调地址
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 支付订单创建成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Payment'
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.post('/',
|
||||
authenticateToken,
|
||||
[
|
||||
body('order_id').isInt({ min: 1 }).withMessage('订单ID必须是正整数'),
|
||||
body('amount').isFloat({ min: 0.01 }).withMessage('支付金额必须大于0'),
|
||||
body('payment_method').isIn(['wechat', 'alipay', 'balance']).withMessage('支付方式无效')
|
||||
],
|
||||
PaymentController.createPayment
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/{paymentId}:
|
||||
* get:
|
||||
* summary: 获取支付订单详情
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: paymentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 支付订单ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Payment'
|
||||
* 403:
|
||||
* description: 无权访问
|
||||
* 404:
|
||||
* description: 支付订单不存在
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.get('/:paymentId',
|
||||
authenticateToken,
|
||||
param('paymentId').isInt({ min: 1 }).withMessage('支付订单ID必须是正整数'),
|
||||
PaymentController.getPayment
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/query/{paymentNo}:
|
||||
* get:
|
||||
* summary: 查询支付状态
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: paymentNo
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 支付订单号
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 查询成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* payment_no:
|
||||
* type: string
|
||||
* description: 支付订单号
|
||||
* status:
|
||||
* type: string
|
||||
* description: 支付状态
|
||||
* amount:
|
||||
* type: number
|
||||
* description: 支付金额
|
||||
* paid_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 支付时间
|
||||
* transaction_id:
|
||||
* type: string
|
||||
* description: 第三方交易号
|
||||
* 403:
|
||||
* description: 无权访问
|
||||
* 404:
|
||||
* description: 支付订单不存在
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.get('/query/:paymentNo',
|
||||
authenticateToken,
|
||||
PaymentController.queryPaymentStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/callback/wechat:
|
||||
* post:
|
||||
* summary: 微信支付回调
|
||||
* tags: [支付管理]
|
||||
* description: 微信支付异步通知接口
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/xml:
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 处理成功
|
||||
* content:
|
||||
* application/xml:
|
||||
* schema:
|
||||
* type: string
|
||||
*/
|
||||
router.post('/callback/wechat', PaymentController.handleWechatCallback);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/callback/alipay:
|
||||
* post:
|
||||
* summary: 支付宝支付回调
|
||||
* tags: [支付管理]
|
||||
* description: 支付宝异步通知接口
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/x-www-form-urlencoded:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 处理成功
|
||||
* content:
|
||||
* text/plain:
|
||||
* schema:
|
||||
* type: string
|
||||
*/
|
||||
router.post('/callback/alipay', PaymentController.handleAlipayCallback);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/{paymentId}/refund:
|
||||
* post:
|
||||
* summary: 申请退款
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: paymentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 支付订单ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - refund_amount
|
||||
* - refund_reason
|
||||
* properties:
|
||||
* refund_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 退款金额
|
||||
* refund_reason:
|
||||
* type: string
|
||||
* description: 退款原因
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 退款申请提交成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Refund'
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 403:
|
||||
* description: 无权操作
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.post('/:paymentId/refund',
|
||||
authenticateToken,
|
||||
[
|
||||
param('paymentId').isInt({ min: 1 }).withMessage('支付订单ID必须是正整数'),
|
||||
body('refund_amount').isFloat({ min: 0.01 }).withMessage('退款金额必须大于0'),
|
||||
body('refund_reason').notEmpty().withMessage('退款原因不能为空')
|
||||
],
|
||||
PaymentController.createRefund
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/refunds/{refundId}:
|
||||
* get:
|
||||
* summary: 获取退款详情
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: refundId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 退款ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Refund'
|
||||
* 403:
|
||||
* description: 无权访问
|
||||
* 404:
|
||||
* description: 退款记录不存在
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.get('/refunds/:refundId',
|
||||
authenticateToken,
|
||||
param('refundId').isInt({ min: 1 }).withMessage('退款ID必须是正整数'),
|
||||
PaymentController.getRefund
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/refunds/{refundId}/process:
|
||||
* put:
|
||||
* summary: 处理退款(管理员)
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: refundId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 退款ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [approved, rejected, completed]
|
||||
* description: 退款状态
|
||||
* process_remark:
|
||||
* type: string
|
||||
* description: 处理备注
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 处理成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Refund'
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.put('/refunds/:refundId/process',
|
||||
authenticateToken,
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('refundId').isInt({ min: 1 }).withMessage('退款ID必须是正整数'),
|
||||
body('status').isIn(['approved', 'rejected', 'completed']).withMessage('退款状态无效')
|
||||
],
|
||||
PaymentController.processRefund
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/statistics:
|
||||
* get:
|
||||
* summary: 获取支付统计信息(管理员)
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: payment_method
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [wechat, alipay, balance]
|
||||
* description: 支付方式
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* $ref: '#/components/schemas/PaymentStatistics'
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.get('/statistics',
|
||||
authenticateToken,
|
||||
requireRole(['admin', 'super_admin']),
|
||||
PaymentController.getPaymentStatistics
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
434
backend/src/routes/travelRegistration.js
Normal file
434
backend/src/routes/travelRegistration.js
Normal file
@@ -0,0 +1,434 @@
|
||||
const express = require('express');
|
||||
const { body, query } = require('express-validator');
|
||||
const TravelRegistrationController = require('../controllers/travelRegistration');
|
||||
const { authenticateUser: authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: TravelRegistration
|
||||
* description: 旅行活动报名管理相关接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* TravelRegistration:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 报名记录ID
|
||||
* travel_plan_id:
|
||||
* type: integer
|
||||
* description: 旅行活动ID
|
||||
* user_id:
|
||||
* type: integer
|
||||
* description: 报名用户ID
|
||||
* message:
|
||||
* type: string
|
||||
* description: 报名留言
|
||||
* emergency_contact:
|
||||
* type: string
|
||||
* description: 紧急联系人
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 报名状态
|
||||
* applied_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 报名时间
|
||||
* responded_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 审核时间
|
||||
* reject_reason:
|
||||
* type: string
|
||||
* description: 拒绝原因
|
||||
* username:
|
||||
* type: string
|
||||
* description: 用户名
|
||||
* real_name:
|
||||
* type: string
|
||||
* description: 真实姓名
|
||||
* avatar_url:
|
||||
* type: string
|
||||
* description: 头像URL
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/{travelId}/register:
|
||||
* post:
|
||||
* summary: 报名参加旅行活动
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: travelId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 旅行活动ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* description: 报名留言
|
||||
* example: 希望能和大家一起愉快旅行
|
||||
* emergencyContact:
|
||||
* type: string
|
||||
* description: 紧急联系人
|
||||
* example: 张三
|
||||
* emergencyPhone:
|
||||
* type: string
|
||||
* description: 紧急联系电话
|
||||
* example: 13800138000
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 报名成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* registration:
|
||||
* $ref: '#/components/schemas/TravelRegistration'
|
||||
* message:
|
||||
* type: string
|
||||
* example: 报名成功,等待审核
|
||||
* 400:
|
||||
* description: 请求参数错误或业务逻辑错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 404:
|
||||
* description: 旅行活动不存在
|
||||
*/
|
||||
router.post('/:travelId/register',
|
||||
authenticate,
|
||||
[
|
||||
body('emergencyContact').optional().isLength({ min: 1, max: 50 }).withMessage('紧急联系人长度应在1-50字符之间'),
|
||||
body('emergencyPhone').optional().isMobilePhone('zh-CN').withMessage('紧急联系电话格式不正确'),
|
||||
body('message').optional().isLength({ max: 500 }).withMessage('报名留言不能超过500字符')
|
||||
],
|
||||
TravelRegistrationController.registerForTravel
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/{registrationId}/cancel:
|
||||
* put:
|
||||
* summary: 取消报名
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: registrationId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 报名记录ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 取消成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: 取消报名成功
|
||||
* 400:
|
||||
* description: 请求参数错误或业务逻辑错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 404:
|
||||
* description: 报名记录不存在
|
||||
*/
|
||||
router.put('/:registrationId/cancel', authenticate, TravelRegistrationController.cancelRegistration);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/my-registrations:
|
||||
* get:
|
||||
* summary: 获取用户的报名记录
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: pageSize
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 50
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 报名状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* registrations:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/TravelRegistration'
|
||||
* pagination:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* pageSize:
|
||||
* type: integer
|
||||
* total:
|
||||
* type: integer
|
||||
* totalPages:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.get('/my-registrations', authenticate, TravelRegistrationController.getUserRegistrations);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/{travelId}/registrations:
|
||||
* get:
|
||||
* summary: 获取旅行活动的报名列表(活动发起者可查看)
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: travelId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 旅行活动ID
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: pageSize
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 50
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 报名状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* registrations:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/TravelRegistration'
|
||||
* pagination:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* pageSize:
|
||||
* type: integer
|
||||
* total:
|
||||
* type: integer
|
||||
* totalPages:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 旅行活动不存在
|
||||
*/
|
||||
router.get('/:travelId/registrations', authenticate, TravelRegistrationController.getTravelRegistrations);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/{registrationId}/review:
|
||||
* put:
|
||||
* summary: 审核报名申请(活动发起者操作)
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: registrationId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 报名记录ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - action
|
||||
* properties:
|
||||
* action:
|
||||
* type: string
|
||||
* enum: [approve, reject]
|
||||
* description: 审核操作
|
||||
* example: approve
|
||||
* rejectReason:
|
||||
* type: string
|
||||
* description: 拒绝原因(拒绝时必填)
|
||||
* example: 活动要求不符合
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 审核成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* registration:
|
||||
* $ref: '#/components/schemas/TravelRegistration'
|
||||
* message:
|
||||
* type: string
|
||||
* example: 审核通过
|
||||
* 400:
|
||||
* description: 请求参数错误或业务逻辑错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 报名记录不存在
|
||||
*/
|
||||
router.put('/:registrationId/review',
|
||||
authenticate,
|
||||
[
|
||||
body('action').isIn(['approve', 'reject']).withMessage('操作类型必须是approve或reject'),
|
||||
body('rejectReason').optional().isLength({ min: 1, max: 200 }).withMessage('拒绝原因长度应在1-200字符之间')
|
||||
],
|
||||
TravelRegistrationController.reviewRegistration
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/{travelId}/stats:
|
||||
* get:
|
||||
* summary: 获取报名统计信息
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: travelId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 旅行活动ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* stats:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_applications:
|
||||
* type: integer
|
||||
* description: 总申请数
|
||||
* pending_count:
|
||||
* type: integer
|
||||
* description: 待审核数
|
||||
* approved_count:
|
||||
* type: integer
|
||||
* description: 已通过数
|
||||
* rejected_count:
|
||||
* type: integer
|
||||
* description: 已拒绝数
|
||||
* cancelled_count:
|
||||
* type: integer
|
||||
* description: 已取消数
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 旅行活动不存在
|
||||
*/
|
||||
router.get('/:travelId/stats', authenticate, TravelRegistrationController.getRegistrationStats);
|
||||
|
||||
module.exports = router;
|
||||
372
backend/src/services/animalClaim.js
Normal file
372
backend/src/services/animalClaim.js
Normal file
@@ -0,0 +1,372 @@
|
||||
const AnimalClaimModel = require('../models/AnimalClaim');
|
||||
const AnimalModel = require('../models/Animal');
|
||||
|
||||
class AnimalClaimService {
|
||||
/**
|
||||
* 申请认领动物
|
||||
* @param {Object} claimData - 认领申请数据
|
||||
* @returns {Object} 认领申请记录
|
||||
*/
|
||||
async createClaim(claimData) {
|
||||
try {
|
||||
const { animal_id, user_id, claim_reason, claim_duration, contact_info } = claimData;
|
||||
|
||||
// 检查动物是否存在且可认领
|
||||
const animal = await AnimalModel.findById(animal_id);
|
||||
if (!animal) {
|
||||
throw new Error('动物不存在');
|
||||
}
|
||||
|
||||
if (animal.status !== 'available') {
|
||||
throw new Error('该动物当前不可认领');
|
||||
}
|
||||
|
||||
// 检查用户是否已经认领过该动物
|
||||
const existingClaim = await AnimalClaimModel.findActiveClaimByUserAndAnimal(user_id, animal_id);
|
||||
if (existingClaim) {
|
||||
throw new Error('您已经认领过该动物,请勿重复申请');
|
||||
}
|
||||
|
||||
// 生成认领订单号
|
||||
const claimNo = this.generateClaimNo();
|
||||
|
||||
// 创建认领申请
|
||||
const claim = await AnimalClaimModel.create({
|
||||
claim_no: claimNo,
|
||||
animal_id,
|
||||
user_id,
|
||||
claim_reason: claim_reason || '喜欢这只动物',
|
||||
claim_duration: claim_duration || 12, // 默认12个月
|
||||
contact_info,
|
||||
status: 'pending',
|
||||
total_amount: animal.price * (claim_duration || 12)
|
||||
});
|
||||
|
||||
return this.sanitizeClaim(claim);
|
||||
} catch (error) {
|
||||
console.error('创建动物认领申请服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消认领申请
|
||||
* @param {number} claimId - 认领申请ID
|
||||
* @param {number} userId - 用户ID
|
||||
* @returns {Object} 更新后的认领申请
|
||||
*/
|
||||
async cancelClaim(claimId, userId) {
|
||||
try {
|
||||
// 获取认领申请
|
||||
const claim = await AnimalClaimModel.findById(claimId);
|
||||
if (!claim) {
|
||||
throw new Error('认领申请不存在');
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (claim.user_id !== userId) {
|
||||
throw new Error('无权操作此认领申请');
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (!['pending', 'approved'].includes(claim.status)) {
|
||||
throw new Error('当前状态不允许取消');
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
const updatedClaim = await AnimalClaimModel.updateStatus(claimId, 'cancelled', {
|
||||
cancelled_at: new Date(),
|
||||
cancel_reason: '用户主动取消'
|
||||
});
|
||||
|
||||
// 如果动物状态是已认领,需要恢复为可认领
|
||||
if (claim.status === 'approved') {
|
||||
await AnimalModel.updateStatus(claim.animal_id, 'available');
|
||||
}
|
||||
|
||||
return this.sanitizeClaim(updatedClaim);
|
||||
} catch (error) {
|
||||
console.error('取消动物认领服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的认领申请列表
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
async getUserClaims(userId, options = {}) {
|
||||
try {
|
||||
const result = await AnimalClaimModel.getUserClaims(userId, options);
|
||||
|
||||
return {
|
||||
data: result.data.map(claim => this.sanitizeClaim(claim)),
|
||||
pagination: result.pagination
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取用户认领申请服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动物的认领申请列表
|
||||
* @param {number} animalId - 动物ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
async getAnimalClaims(animalId, options = {}) {
|
||||
try {
|
||||
const result = await AnimalClaimModel.getAnimalClaims(animalId, options);
|
||||
|
||||
return {
|
||||
data: result.data.map(claim => this.sanitizeClaim(claim)),
|
||||
pagination: result.pagination
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取动物认领申请服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有认领申请列表(管理员)
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
async getAllClaims(options = {}) {
|
||||
try {
|
||||
const result = await AnimalClaimModel.getAllClaims(options);
|
||||
|
||||
return {
|
||||
data: result.data.map(claim => this.sanitizeClaim(claim)),
|
||||
pagination: result.pagination
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取所有认领申请服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核认领申请
|
||||
* @param {number} claimId - 认领申请ID
|
||||
* @param {string} status - 审核状态
|
||||
* @param {Object} reviewData - 审核数据
|
||||
* @returns {Object} 更新后的认领申请
|
||||
*/
|
||||
async reviewClaim(claimId, status, reviewData = {}) {
|
||||
try {
|
||||
const { reviewed_by, review_remark } = reviewData;
|
||||
|
||||
// 获取认领申请
|
||||
const claim = await AnimalClaimModel.findById(claimId);
|
||||
if (!claim) {
|
||||
throw new Error('认领申请不存在');
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (claim.status !== 'pending') {
|
||||
throw new Error('只能审核待审核的申请');
|
||||
}
|
||||
|
||||
// 验证审核状态
|
||||
const validStatuses = ['approved', 'rejected'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error('无效的审核状态');
|
||||
}
|
||||
|
||||
// 更新认领申请状态
|
||||
const updateData = {
|
||||
reviewed_by,
|
||||
review_remark,
|
||||
reviewed_at: new Date()
|
||||
};
|
||||
|
||||
if (status === 'approved') {
|
||||
updateData.approved_at = new Date();
|
||||
updateData.start_date = new Date();
|
||||
|
||||
// 计算结束日期
|
||||
const endDate = new Date();
|
||||
endDate.setMonth(endDate.getMonth() + claim.claim_duration);
|
||||
updateData.end_date = endDate;
|
||||
|
||||
// 更新动物状态为已认领
|
||||
await AnimalModel.updateStatus(claim.animal_id, 'claimed');
|
||||
|
||||
// 增加动物认领次数
|
||||
await AnimalModel.incrementClaimCount(claim.animal_id);
|
||||
}
|
||||
|
||||
const updatedClaim = await AnimalClaimModel.updateStatus(claimId, status, updateData);
|
||||
|
||||
return this.sanitizeClaim(updatedClaim);
|
||||
} catch (error) {
|
||||
console.error('审核认领申请服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 续期认领
|
||||
* @param {number} claimId - 认领申请ID
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {Object} renewData - 续期数据
|
||||
* @returns {Object} 续期结果
|
||||
*/
|
||||
async renewClaim(claimId, userId, renewData) {
|
||||
try {
|
||||
const { duration, payment_method } = renewData;
|
||||
|
||||
// 获取认领申请
|
||||
const claim = await AnimalClaimModel.findById(claimId);
|
||||
if (!claim) {
|
||||
throw new Error('认领申请不存在');
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (claim.user_id !== userId) {
|
||||
throw new Error('无权操作此认领申请');
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (claim.status !== 'approved') {
|
||||
throw new Error('只有已通过的认领申请才能续期');
|
||||
}
|
||||
|
||||
// 检查是否即将到期(提前30天可以续期)
|
||||
const now = new Date();
|
||||
const endDate = new Date(claim.end_date);
|
||||
const daysUntilExpiry = Math.ceil((endDate - now) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilExpiry > 30) {
|
||||
throw new Error('距离到期还有超过30天,暂时无法续期');
|
||||
}
|
||||
|
||||
// 获取动物信息计算续期费用
|
||||
const animal = await AnimalModel.findById(claim.animal_id);
|
||||
const renewAmount = animal.price * duration;
|
||||
|
||||
// 创建续期记录
|
||||
const renewRecord = await AnimalClaimModel.createRenewal({
|
||||
claim_id: claimId,
|
||||
duration,
|
||||
amount: renewAmount,
|
||||
payment_method,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
return {
|
||||
renewal: renewRecord,
|
||||
amount: renewAmount,
|
||||
message: '续期申请已提交,请完成支付'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('续期认领服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认领统计信息
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @returns {Object} 统计信息
|
||||
*/
|
||||
async getClaimStatistics(filters = {}) {
|
||||
try {
|
||||
const statistics = await AnimalClaimModel.getClaimStatistics(filters);
|
||||
return statistics;
|
||||
} catch (error) {
|
||||
console.error('获取认领统计服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查认领权限
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} animalId - 动物ID
|
||||
* @returns {boolean} 是否有权限
|
||||
*/
|
||||
async checkClaimPermission(userId, animalId) {
|
||||
try {
|
||||
// 检查动物是否存在
|
||||
const animal = await AnimalModel.findById(animalId);
|
||||
if (!animal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查动物状态
|
||||
if (animal.status !== 'available') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查用户是否已有活跃的认领申请
|
||||
const existingClaim = await AnimalClaimModel.findActiveClaimByUserAndAnimal(userId, animalId);
|
||||
if (existingClaim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('检查认领权限服务错误:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成认领订单号
|
||||
* @returns {string} 认领订单号
|
||||
*/
|
||||
generateClaimNo() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const timestamp = now.getTime().toString().slice(-6);
|
||||
|
||||
return `CLAIM${year}${month}${day}${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理认领申请数据
|
||||
* @param {Object} claim - 认领申请数据
|
||||
* @returns {Object} 清理后的数据
|
||||
*/
|
||||
sanitizeClaim(claim) {
|
||||
if (!claim) return null;
|
||||
|
||||
return {
|
||||
id: claim.id,
|
||||
claim_no: claim.claim_no,
|
||||
animal_id: claim.animal_id,
|
||||
animal_name: claim.animal_name,
|
||||
animal_type: claim.animal_type,
|
||||
animal_image: claim.animal_image,
|
||||
user_id: claim.user_id,
|
||||
username: claim.username,
|
||||
user_phone: claim.user_phone,
|
||||
claim_reason: claim.claim_reason,
|
||||
claim_duration: claim.claim_duration,
|
||||
total_amount: parseFloat(claim.total_amount || 0),
|
||||
contact_info: claim.contact_info,
|
||||
status: claim.status,
|
||||
start_date: claim.start_date,
|
||||
end_date: claim.end_date,
|
||||
reviewed_by: claim.reviewed_by,
|
||||
reviewer_name: claim.reviewer_name,
|
||||
review_remark: claim.review_remark,
|
||||
reviewed_at: claim.reviewed_at,
|
||||
approved_at: claim.approved_at,
|
||||
cancelled_at: claim.cancelled_at,
|
||||
cancel_reason: claim.cancel_reason,
|
||||
created_at: claim.created_at,
|
||||
updated_at: claim.updated_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AnimalClaimService();
|
||||
@@ -254,6 +254,53 @@ class OrderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付订单
|
||||
* @param {number} orderId - 订单ID
|
||||
* @param {Object} paymentData - 支付数据
|
||||
* @returns {Object} 支付结果
|
||||
*/
|
||||
async payOrder(orderId, paymentData) {
|
||||
try {
|
||||
// 获取订单信息
|
||||
const order = await this.getOrderById(orderId);
|
||||
if (!order) {
|
||||
throw new Error('订单不存在');
|
||||
}
|
||||
|
||||
// 检查订单状态
|
||||
if (order.status !== 'pending') {
|
||||
throw new Error('订单状态不允许支付');
|
||||
}
|
||||
|
||||
// 检查订单金额
|
||||
if (order.total_amount !== paymentData.amount) {
|
||||
throw new Error('支付金额与订单金额不符');
|
||||
}
|
||||
|
||||
// 创建支付订单
|
||||
const PaymentService = require('../payment');
|
||||
const payment = await PaymentService.createPayment({
|
||||
order_id: orderId,
|
||||
user_id: order.user_id,
|
||||
amount: order.total_amount,
|
||||
payment_method: paymentData.payment_method,
|
||||
return_url: paymentData.return_url
|
||||
});
|
||||
|
||||
// 更新订单状态为支付中
|
||||
await this.updateOrderStatus(orderId, 'paying');
|
||||
|
||||
return {
|
||||
payment,
|
||||
order: await this.getOrderById(orderId)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('支付订单服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单统计信息
|
||||
* @param {number} merchantId - 商家ID
|
||||
|
||||
529
backend/src/services/payment.js
Normal file
529
backend/src/services/payment.js
Normal file
@@ -0,0 +1,529 @@
|
||||
const database = require('../config/database');
|
||||
const crypto = require('crypto');
|
||||
|
||||
class PaymentService {
|
||||
/**
|
||||
* 创建支付订单
|
||||
* @param {Object} paymentData - 支付数据
|
||||
* @returns {Promise<Object>} 支付订单信息
|
||||
*/
|
||||
async createPayment(paymentData) {
|
||||
try {
|
||||
const {
|
||||
order_id,
|
||||
user_id,
|
||||
amount,
|
||||
payment_method,
|
||||
payment_channel = 'wechat'
|
||||
} = paymentData;
|
||||
|
||||
// 生成支付订单号
|
||||
const payment_no = this.generatePaymentNo();
|
||||
|
||||
const query = `
|
||||
INSERT INTO payments (
|
||||
payment_no, order_id, user_id, amount,
|
||||
payment_method, payment_channel, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'pending')
|
||||
`;
|
||||
|
||||
const params = [
|
||||
payment_no, order_id, user_id, amount,
|
||||
payment_method, payment_channel
|
||||
];
|
||||
|
||||
const result = await database.query(query, params);
|
||||
|
||||
// 获取创建的支付订单
|
||||
const payment = await this.getPaymentById(result.insertId);
|
||||
|
||||
// 根据支付方式生成支付参数
|
||||
const paymentParams = await this.generatePaymentParams(payment);
|
||||
|
||||
return {
|
||||
...payment,
|
||||
...paymentParams
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('创建支付订单失败:', error);
|
||||
throw new Error('创建支付订单失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付订单详情
|
||||
* @param {number} paymentId - 支付ID
|
||||
* @returns {Promise<Object>} 支付订单信息
|
||||
*/
|
||||
async getPaymentById(paymentId) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
p.*,
|
||||
o.order_number,
|
||||
o.total_amount as order_amount,
|
||||
u.username
|
||||
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.is_deleted = 0
|
||||
`;
|
||||
|
||||
const [payments] = await database.query(query, [paymentId]);
|
||||
|
||||
if (payments.length === 0) {
|
||||
throw new Error('支付订单不存在');
|
||||
}
|
||||
|
||||
return payments[0];
|
||||
} catch (error) {
|
||||
console.error('获取支付订单失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付号获取支付订单
|
||||
* @param {string} paymentNo - 支付订单号
|
||||
* @returns {Promise<Object>} 支付订单信息
|
||||
*/
|
||||
async getPaymentByNo(paymentNo) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
p.*,
|
||||
o.order_number,
|
||||
o.total_amount as order_amount,
|
||||
u.username
|
||||
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.is_deleted = 0
|
||||
`;
|
||||
|
||||
const [payments] = await database.query(query, [paymentNo]);
|
||||
|
||||
if (payments.length === 0) {
|
||||
throw new Error('支付订单不存在');
|
||||
}
|
||||
|
||||
return payments[0];
|
||||
} catch (error) {
|
||||
console.error('获取支付订单失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付状态
|
||||
* @param {string} paymentNo - 支付订单号
|
||||
* @param {string} status - 支付状态
|
||||
* @param {Object} extraData - 额外数据
|
||||
* @returns {Promise<Object>} 更新后的支付订单
|
||||
*/
|
||||
async updatePaymentStatus(paymentNo, status, extraData = {}) {
|
||||
try {
|
||||
const {
|
||||
transaction_id,
|
||||
paid_at,
|
||||
failure_reason
|
||||
} = extraData;
|
||||
|
||||
let query = `
|
||||
UPDATE payments
|
||||
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
const params = [status];
|
||||
|
||||
if (transaction_id) {
|
||||
query += ', transaction_id = ?';
|
||||
params.push(transaction_id);
|
||||
}
|
||||
|
||||
if (paid_at) {
|
||||
query += ', paid_at = ?';
|
||||
params.push(paid_at);
|
||||
}
|
||||
|
||||
if (failure_reason) {
|
||||
query += ', failure_reason = ?';
|
||||
params.push(failure_reason);
|
||||
}
|
||||
|
||||
query += ' WHERE payment_no = ? AND is_deleted = 0';
|
||||
params.push(paymentNo);
|
||||
|
||||
const result = await database.query(query, params);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
throw new Error('支付订单不存在');
|
||||
}
|
||||
|
||||
return await this.getPaymentByNo(paymentNo);
|
||||
} catch (error) {
|
||||
console.error('更新支付状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付回调
|
||||
* @param {Object} callbackData - 回调数据
|
||||
* @returns {Promise<Object>} 处理结果
|
||||
*/
|
||||
async handlePaymentCallback(callbackData) {
|
||||
try {
|
||||
const {
|
||||
payment_no,
|
||||
transaction_id,
|
||||
status,
|
||||
paid_amount,
|
||||
paid_at
|
||||
} = callbackData;
|
||||
|
||||
// 获取支付订单
|
||||
const payment = await this.getPaymentByNo(payment_no);
|
||||
|
||||
// 验证金额
|
||||
if (status === 'paid' && parseFloat(paid_amount) !== parseFloat(payment.amount)) {
|
||||
throw new Error('支付金额不匹配');
|
||||
}
|
||||
|
||||
// 更新支付状态
|
||||
const updatedPayment = await this.updatePaymentStatus(payment_no, status, {
|
||||
transaction_id,
|
||||
paid_at: paid_at || new Date()
|
||||
});
|
||||
|
||||
// 如果支付成功,更新订单状态
|
||||
if (status === 'paid') {
|
||||
await this.updateOrderAfterPayment(payment.order_id);
|
||||
}
|
||||
|
||||
return updatedPayment;
|
||||
} catch (error) {
|
||||
console.error('处理支付回调失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后更新订单状态
|
||||
* @param {number} orderId - 订单ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateOrderAfterPayment(orderId) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE orders
|
||||
SET
|
||||
payment_status = 'paid',
|
||||
order_status = 'confirmed',
|
||||
paid_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_deleted = 0
|
||||
`;
|
||||
|
||||
await database.query(query, [orderId]);
|
||||
} catch (error) {
|
||||
console.error('更新订单支付状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
* @param {Object} refundData - 退款数据
|
||||
* @returns {Promise<Object>} 退款申请结果
|
||||
*/
|
||||
async createRefund(refundData) {
|
||||
try {
|
||||
const {
|
||||
payment_id,
|
||||
refund_amount,
|
||||
refund_reason,
|
||||
user_id
|
||||
} = refundData;
|
||||
|
||||
// 获取支付订单
|
||||
const payment = await this.getPaymentById(payment_id);
|
||||
|
||||
// 验证退款金额
|
||||
if (parseFloat(refund_amount) > parseFloat(payment.amount)) {
|
||||
throw new Error('退款金额不能超过支付金额');
|
||||
}
|
||||
|
||||
// 生成退款订单号
|
||||
const refund_no = this.generateRefundNo();
|
||||
|
||||
const query = `
|
||||
INSERT INTO refunds (
|
||||
refund_no, payment_id, order_id, user_id,
|
||||
refund_amount, refund_reason, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'pending')
|
||||
`;
|
||||
|
||||
const params = [
|
||||
refund_no, payment_id, payment.order_id, user_id,
|
||||
refund_amount, refund_reason
|
||||
];
|
||||
|
||||
const result = await database.query(query, params);
|
||||
|
||||
return await this.getRefundById(result.insertId);
|
||||
} catch (error) {
|
||||
console.error('创建退款申请失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取退款详情
|
||||
* @param {number} refundId - 退款ID
|
||||
* @returns {Promise<Object>} 退款信息
|
||||
*/
|
||||
async getRefundById(refundId) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
r.*,
|
||||
p.payment_no,
|
||||
p.amount as payment_amount,
|
||||
o.order_number,
|
||||
u.username
|
||||
FROM refunds r
|
||||
LEFT JOIN payments p ON r.payment_id = p.id
|
||||
LEFT JOIN orders o ON r.order_id = o.id
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
WHERE r.id = ? AND r.is_deleted = 0
|
||||
`;
|
||||
|
||||
const [refunds] = await database.query(query, [refundId]);
|
||||
|
||||
if (refunds.length === 0) {
|
||||
throw new Error('退款记录不存在');
|
||||
}
|
||||
|
||||
return refunds[0];
|
||||
} catch (error) {
|
||||
console.error('获取退款详情失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理退款
|
||||
* @param {number} refundId - 退款ID
|
||||
* @param {string} status - 退款状态
|
||||
* @param {Object} extraData - 额外数据
|
||||
* @returns {Promise<Object>} 处理结果
|
||||
*/
|
||||
async processRefund(refundId, status, extraData = {}) {
|
||||
try {
|
||||
const {
|
||||
refund_transaction_id,
|
||||
processed_by,
|
||||
process_remark
|
||||
} = extraData;
|
||||
|
||||
let query = `
|
||||
UPDATE refunds
|
||||
SET
|
||||
status = ?,
|
||||
processed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
const params = [status];
|
||||
|
||||
if (refund_transaction_id) {
|
||||
query += ', refund_transaction_id = ?';
|
||||
params.push(refund_transaction_id);
|
||||
}
|
||||
|
||||
if (processed_by) {
|
||||
query += ', processed_by = ?';
|
||||
params.push(processed_by);
|
||||
}
|
||||
|
||||
if (process_remark) {
|
||||
query += ', process_remark = ?';
|
||||
params.push(process_remark);
|
||||
}
|
||||
|
||||
query += ' WHERE id = ? AND is_deleted = 0';
|
||||
params.push(refundId);
|
||||
|
||||
const result = await database.query(query, params);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
throw new Error('退款记录不存在');
|
||||
}
|
||||
|
||||
// 如果退款成功,更新支付和订单状态
|
||||
if (status === 'completed') {
|
||||
const refund = await this.getRefundById(refundId);
|
||||
await this.updatePaymentStatus(refund.payment_no, 'refunded');
|
||||
await this.updateOrderAfterRefund(refund.order_id);
|
||||
}
|
||||
|
||||
return await this.getRefundById(refundId);
|
||||
} catch (error) {
|
||||
console.error('处理退款失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款成功后更新订单状态
|
||||
* @param {number} orderId - 订单ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateOrderAfterRefund(orderId) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE orders
|
||||
SET
|
||||
payment_status = 'refunded',
|
||||
order_status = 'cancelled',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_deleted = 0
|
||||
`;
|
||||
|
||||
await database.query(query, [orderId]);
|
||||
} catch (error) {
|
||||
console.error('更新订单退款状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成支付订单号
|
||||
* @returns {string} 支付订单号
|
||||
*/
|
||||
generatePaymentNo() {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `PAY${timestamp}${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成退款订单号
|
||||
* @returns {string} 退款订单号
|
||||
*/
|
||||
generateRefundNo() {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `REF${timestamp}${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成支付参数(模拟)
|
||||
* @param {Object} payment - 支付订单
|
||||
* @returns {Promise<Object>} 支付参数
|
||||
*/
|
||||
async generatePaymentParams(payment) {
|
||||
try {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
const nonceStr = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// 模拟微信支付参数
|
||||
if (payment.payment_channel === 'wechat') {
|
||||
return {
|
||||
timeStamp: timestamp,
|
||||
nonceStr: nonceStr,
|
||||
package: `prepay_id=wx${timestamp}${nonceStr}`,
|
||||
signType: 'MD5',
|
||||
paySign: this.generateSign({
|
||||
timeStamp: timestamp,
|
||||
nonceStr: nonceStr,
|
||||
package: `prepay_id=wx${timestamp}${nonceStr}`,
|
||||
signType: 'MD5'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// 模拟支付宝参数
|
||||
if (payment.payment_channel === 'alipay') {
|
||||
return {
|
||||
orderString: `app_id=2021000000000000&method=alipay.trade.app.pay&charset=utf-8&sign_type=RSA2×tamp=${timestamp}&version=1.0¬ify_url=https://api.jiebanke.com/payment/alipay/notify&biz_content={"out_trade_no":"${payment.payment_no}","total_amount":"${payment.amount}","subject":"订单支付","product_code":"QUICK_MSECURITY_PAY"}`
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error('生成支付参数失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名(模拟)
|
||||
* @param {Object} params - 参数
|
||||
* @returns {string} 签名
|
||||
*/
|
||||
generateSign(params) {
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map(key => `${key}=${params[key]}`)
|
||||
.join('&');
|
||||
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(sortedParams + '&key=your_secret_key')
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付统计信息
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @returns {Promise<Object>} 统计信息
|
||||
*/
|
||||
async getPaymentStatistics(filters = {}) {
|
||||
try {
|
||||
const { start_date, end_date, payment_method } = filters;
|
||||
|
||||
let whereClause = 'WHERE p.is_deleted = 0';
|
||||
const params = [];
|
||||
|
||||
if (start_date) {
|
||||
whereClause += ' AND p.created_at >= ?';
|
||||
params.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
whereClause += ' AND p.created_at <= ?';
|
||||
params.push(end_date);
|
||||
}
|
||||
|
||||
if (payment_method) {
|
||||
whereClause += ' AND p.payment_method = ?';
|
||||
params.push(payment_method);
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(*) as total_payments,
|
||||
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as successful_payments,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_payments,
|
||||
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded_payments,
|
||||
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as total_amount,
|
||||
AVG(CASE WHEN status = 'paid' THEN amount ELSE NULL END) as average_amount,
|
||||
payment_method,
|
||||
payment_channel
|
||||
FROM payments p
|
||||
${whereClause}
|
||||
GROUP BY payment_method, payment_channel
|
||||
`;
|
||||
|
||||
const [statistics] = await database.query(query, params);
|
||||
|
||||
return statistics;
|
||||
} catch (error) {
|
||||
console.error('获取支付统计失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new PaymentService();
|
||||
356
backend/src/services/travelRegistration.js
Normal file
356
backend/src/services/travelRegistration.js
Normal file
@@ -0,0 +1,356 @@
|
||||
const { query } = require('../config/database');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
/**
|
||||
* 旅行活动报名服务
|
||||
*/
|
||||
class TravelRegistrationService {
|
||||
/**
|
||||
* 用户报名参加旅行活动
|
||||
*/
|
||||
static async registerForTravel(registrationData) {
|
||||
try {
|
||||
const { userId, travelId, message, emergencyContact, emergencyPhone } = registrationData;
|
||||
|
||||
// 检查旅行活动是否存在且可报名
|
||||
const travelSql = `
|
||||
SELECT tp.*, u.username as organizer_name
|
||||
FROM travel_plans tp
|
||||
INNER JOIN users u ON tp.user_id = u.id
|
||||
WHERE tp.id = ? AND tp.status = 'active'
|
||||
`;
|
||||
const travels = await query(travelSql, [travelId]);
|
||||
|
||||
if (travels.length === 0) {
|
||||
throw new AppError('旅行活动不存在或已关闭', 404);
|
||||
}
|
||||
|
||||
const travel = travels[0];
|
||||
|
||||
// 检查是否为活动发起者
|
||||
if (travel.user_id === userId) {
|
||||
throw new AppError('不能报名自己发起的活动', 400);
|
||||
}
|
||||
|
||||
// 检查是否已经报名
|
||||
const existingSql = 'SELECT id FROM travel_matches WHERE travel_plan_id = ? AND user_id = ?';
|
||||
const existing = await query(existingSql, [travelId, userId]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new AppError('您已经报名过此活动', 400);
|
||||
}
|
||||
|
||||
// 检查活动是否已满员
|
||||
if (travel.current_participants >= travel.max_participants) {
|
||||
throw new AppError('活动已满员', 400);
|
||||
}
|
||||
|
||||
// 创建报名记录
|
||||
const insertSql = `
|
||||
INSERT INTO travel_matches (
|
||||
travel_plan_id, user_id, message, emergency_contact, emergency_phone,
|
||||
status, applied_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', NOW(), NOW(), NOW())
|
||||
`;
|
||||
|
||||
const result = await query(insertSql, [
|
||||
travelId, userId, message, emergencyContact, emergencyPhone
|
||||
]);
|
||||
|
||||
// 获取完整的报名信息
|
||||
const registrationSql = `
|
||||
SELECT tm.*, u.username, u.real_name, u.avatar_url, u.phone
|
||||
FROM travel_matches tm
|
||||
INNER JOIN users u ON tm.user_id = u.id
|
||||
WHERE tm.id = ?
|
||||
`;
|
||||
const registrations = await query(registrationSql, [result.insertId]);
|
||||
|
||||
return this.sanitizeRegistration(registrations[0]);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消报名
|
||||
*/
|
||||
static async cancelRegistration(registrationId, userId) {
|
||||
try {
|
||||
// 检查报名记录是否存在且属于当前用户
|
||||
const checkSql = `
|
||||
SELECT tm.*, tp.status as travel_status
|
||||
FROM travel_matches tm
|
||||
INNER JOIN travel_plans tp ON tm.travel_plan_id = tp.id
|
||||
WHERE tm.id = ? AND tm.user_id = ?
|
||||
`;
|
||||
const registrations = await query(checkSql, [registrationId, userId]);
|
||||
|
||||
if (registrations.length === 0) {
|
||||
throw new AppError('报名记录不存在', 404);
|
||||
}
|
||||
|
||||
const registration = registrations[0];
|
||||
|
||||
// 检查是否可以取消
|
||||
if (registration.status === 'cancelled') {
|
||||
throw new AppError('报名已取消', 400);
|
||||
}
|
||||
|
||||
if (registration.travel_status === 'completed') {
|
||||
throw new AppError('活动已结束,无法取消报名', 400);
|
||||
}
|
||||
|
||||
// 更新报名状态
|
||||
const updateSql = `
|
||||
UPDATE travel_matches
|
||||
SET status = 'cancelled', updated_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
await query(updateSql, [registrationId]);
|
||||
|
||||
// 如果之前是已通过状态,需要减少活动参与人数
|
||||
if (registration.status === 'approved') {
|
||||
const decreaseSql = `
|
||||
UPDATE travel_plans
|
||||
SET current_participants = current_participants - 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
await query(decreaseSql, [registration.travel_plan_id]);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的报名记录
|
||||
*/
|
||||
static async getUserRegistrations(searchParams) {
|
||||
try {
|
||||
const { userId, page = 1, pageSize = 10, status } = searchParams;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
let sql = `
|
||||
SELECT tm.*, tp.destination, tp.start_date, tp.end_date, tp.budget,
|
||||
tp.title, tp.status as travel_status, u.username as organizer_name
|
||||
FROM travel_matches tm
|
||||
INNER JOIN travel_plans tp ON tm.travel_plan_id = tp.id
|
||||
INNER JOIN users u ON tp.user_id = u.id
|
||||
WHERE tm.user_id = ?
|
||||
`;
|
||||
const params = [userId];
|
||||
|
||||
if (status) {
|
||||
sql += ' AND tm.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const countSql = `SELECT COUNT(*) as total FROM (${sql}) as count_query`;
|
||||
const countResult = await query(countSql, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 添加分页和排序
|
||||
sql += ' ORDER BY tm.applied_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const registrations = await query(sql, params);
|
||||
|
||||
return {
|
||||
registrations: registrations.map(reg => this.sanitizeRegistration(reg)),
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
total: parseInt(total),
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旅行活动的报名列表
|
||||
*/
|
||||
static async getTravelRegistrations(searchParams) {
|
||||
try {
|
||||
const { travelId, organizerId, page = 1, pageSize = 10, status } = searchParams;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 验证活动发起者权限
|
||||
const travelSql = 'SELECT user_id FROM travel_plans WHERE id = ?';
|
||||
const travels = await query(travelSql, [travelId]);
|
||||
|
||||
if (travels.length === 0) {
|
||||
throw new AppError('旅行活动不存在', 404);
|
||||
}
|
||||
|
||||
if (travels[0].user_id !== organizerId) {
|
||||
throw new AppError('无权查看此活动的报名信息', 403);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT tm.*, u.username, u.real_name, u.avatar_url, u.phone, u.gender, u.age
|
||||
FROM travel_matches tm
|
||||
INNER JOIN users u ON tm.user_id = u.id
|
||||
WHERE tm.travel_plan_id = ?
|
||||
`;
|
||||
const params = [travelId];
|
||||
|
||||
if (status) {
|
||||
sql += ' AND tm.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const countSql = `SELECT COUNT(*) as total FROM (${sql}) as count_query`;
|
||||
const countResult = await query(countSql, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 添加分页和排序
|
||||
sql += ' ORDER BY tm.applied_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const registrations = await query(sql, params);
|
||||
|
||||
return {
|
||||
registrations: registrations.map(reg => this.sanitizeRegistration(reg)),
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
total: parseInt(total),
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核报名申请
|
||||
*/
|
||||
static async reviewRegistration(reviewData) {
|
||||
try {
|
||||
const { registrationId, organizerId, action, rejectReason } = reviewData;
|
||||
|
||||
// 检查报名记录和权限
|
||||
const checkSql = `
|
||||
SELECT tm.*, tp.user_id as organizer_id, tp.max_participants, tp.current_participants
|
||||
FROM travel_matches tm
|
||||
INNER JOIN travel_plans tp ON tm.travel_plan_id = tp.id
|
||||
WHERE tm.id = ?
|
||||
`;
|
||||
const registrations = await query(checkSql, [registrationId]);
|
||||
|
||||
if (registrations.length === 0) {
|
||||
throw new AppError('报名记录不存在', 404);
|
||||
}
|
||||
|
||||
const registration = registrations[0];
|
||||
|
||||
if (registration.organizer_id !== organizerId) {
|
||||
throw new AppError('无权操作此报名记录', 403);
|
||||
}
|
||||
|
||||
if (registration.status !== 'pending') {
|
||||
throw new AppError('此报名已处理过', 400);
|
||||
}
|
||||
|
||||
// 如果是通过申请,检查是否还有名额
|
||||
if (action === 'approve' && registration.current_participants >= registration.max_participants) {
|
||||
throw new AppError('活动已满员,无法通过更多申请', 400);
|
||||
}
|
||||
|
||||
// 更新报名状态
|
||||
const updateSql = `
|
||||
UPDATE travel_matches
|
||||
SET status = ?, reject_reason = ?, responded_at = NOW(), updated_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
const newStatus = action === 'approve' ? 'approved' : 'rejected';
|
||||
await query(updateSql, [newStatus, rejectReason || null, registrationId]);
|
||||
|
||||
// 如果通过申请,增加活动参与人数
|
||||
if (action === 'approve') {
|
||||
const increaseSql = `
|
||||
UPDATE travel_plans
|
||||
SET current_participants = current_participants + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
await query(increaseSql, [registration.travel_plan_id]);
|
||||
}
|
||||
|
||||
// 返回更新后的报名信息
|
||||
const resultSql = `
|
||||
SELECT tm.*, u.username, u.real_name, u.avatar_url
|
||||
FROM travel_matches tm
|
||||
INNER JOIN users u ON tm.user_id = u.id
|
||||
WHERE tm.id = ?
|
||||
`;
|
||||
const results = await query(resultSql, [registrationId]);
|
||||
|
||||
return this.sanitizeRegistration(results[0]);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取报名统计信息
|
||||
*/
|
||||
static async getRegistrationStats(travelId, organizerId) {
|
||||
try {
|
||||
// 验证权限
|
||||
const travelSql = 'SELECT user_id FROM travel_plans WHERE id = ?';
|
||||
const travels = await query(travelSql, [travelId]);
|
||||
|
||||
if (travels.length === 0) {
|
||||
throw new AppError('旅行活动不存在', 404);
|
||||
}
|
||||
|
||||
if (travels[0].user_id !== organizerId) {
|
||||
throw new AppError('无权查看此活动的统计信息', 403);
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
const statsSql = `
|
||||
SELECT
|
||||
COUNT(*) as total_applications,
|
||||
COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending_count,
|
||||
COUNT(CASE WHEN status = 'approved' THEN 1 END) as approved_count,
|
||||
COUNT(CASE WHEN status = 'rejected' THEN 1 END) as rejected_count,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count
|
||||
FROM travel_matches
|
||||
WHERE travel_plan_id = ?
|
||||
`;
|
||||
|
||||
const stats = await query(statsSql, [travelId]);
|
||||
return stats[0];
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理报名信息,移除敏感数据
|
||||
*/
|
||||
static sanitizeRegistration(registration) {
|
||||
if (!registration) return null;
|
||||
|
||||
const sanitized = { ...registration };
|
||||
|
||||
// 移除敏感信息
|
||||
delete sanitized.emergency_phone;
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TravelRegistrationService;
|
||||
248
backend/src/utils/email.js
Normal file
248
backend/src/utils/email.js
Normal file
@@ -0,0 +1,248 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
/**
|
||||
* 邮件发送工具类
|
||||
* 支持SMTP和其他邮件服务提供商
|
||||
*/
|
||||
class EmailService {
|
||||
constructor() {
|
||||
this.transporter = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化邮件传输器
|
||||
*/
|
||||
init() {
|
||||
try {
|
||||
// 根据环境变量配置邮件服务
|
||||
const emailConfig = {
|
||||
host: process.env.SMTP_HOST || 'smtp.qq.com',
|
||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS
|
||||
}
|
||||
};
|
||||
|
||||
// 如果没有配置SMTP,使用测试账户
|
||||
if (!process.env.SMTP_USER) {
|
||||
console.warn('⚠️ 未配置SMTP邮件服务,将使用测试模式');
|
||||
this.transporter = nodemailer.createTransporter({
|
||||
host: 'smtp.ethereal.email',
|
||||
port: 587,
|
||||
auth: {
|
||||
user: 'ethereal.user@ethereal.email',
|
||||
pass: 'ethereal.pass'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.transporter = nodemailer.createTransporter(emailConfig);
|
||||
}
|
||||
|
||||
console.log('✅ 邮件服务初始化成功');
|
||||
} catch (error) {
|
||||
console.error('❌ 邮件服务初始化失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件
|
||||
* @param {Object} options - 邮件选项
|
||||
* @param {string} options.to - 收件人邮箱
|
||||
* @param {string} options.subject - 邮件主题
|
||||
* @param {string} options.text - 纯文本内容
|
||||
* @param {string} options.html - HTML内容
|
||||
* @param {string} options.from - 发件人(可选)
|
||||
*/
|
||||
async sendEmail(options) {
|
||||
try {
|
||||
if (!this.transporter) {
|
||||
throw new Error('邮件服务未初始化');
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: options.from || process.env.SMTP_FROM || '"结伴客" <noreply@jiebanke.com>',
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
html: options.html
|
||||
};
|
||||
|
||||
const info = await this.transporter.sendMail(mailOptions);
|
||||
|
||||
console.log('📧 邮件发送成功:', {
|
||||
messageId: info.messageId,
|
||||
to: options.to,
|
||||
subject: options.subject
|
||||
});
|
||||
|
||||
// 如果是测试环境,输出预览链接
|
||||
if (process.env.NODE_ENV === 'development' && !process.env.SMTP_USER) {
|
||||
console.log('📧 邮件预览链接:', nodemailer.getTestMessageUrl(info));
|
||||
}
|
||||
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error('❌ 邮件发送失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码邮件
|
||||
* @param {string} to - 收件人邮箱
|
||||
* @param {string} code - 验证码
|
||||
* @param {number} expiresInMinutes - 过期时间(分钟)
|
||||
*/
|
||||
async sendVerificationCode(to, code, expiresInMinutes = 10) {
|
||||
const subject = '结伴客 - 邮箱验证码';
|
||||
const html = `
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="color: #2c3e50; margin: 0;">结伴客</h1>
|
||||
<p style="color: #7f8c8d; margin: 5px 0;">让旅行更有温度</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px; text-align: center;">
|
||||
<h2 style="color: #2c3e50; margin-bottom: 20px;">邮箱验证</h2>
|
||||
<p style="color: #555; margin-bottom: 30px;">您的验证码是:</p>
|
||||
<div style="background: #fff; padding: 20px; border-radius: 4px; border: 2px dashed #3498db; display: inline-block;">
|
||||
<span style="font-size: 32px; font-weight: bold; color: #3498db; letter-spacing: 5px;">${code}</span>
|
||||
</div>
|
||||
<p style="color: #7f8c8d; margin-top: 30px; font-size: 14px;">
|
||||
验证码将在 ${expiresInMinutes} 分钟后过期,请及时使用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; text-align: center;">
|
||||
<p style="color: #7f8c8d; font-size: 12px; margin: 0;">
|
||||
如果这不是您的操作,请忽略此邮件。<br>
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return this.sendEmail({ to, subject, html });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送密码重置邮件
|
||||
* @param {string} to - 收件人邮箱
|
||||
* @param {string} resetUrl - 重置链接
|
||||
* @param {number} expiresInMinutes - 过期时间(分钟)
|
||||
*/
|
||||
async sendPasswordReset(to, resetUrl, expiresInMinutes = 30) {
|
||||
const subject = '结伴客 - 密码重置';
|
||||
const html = `
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="color: #2c3e50; margin: 0;">结伴客</h1>
|
||||
<p style="color: #7f8c8d; margin: 5px 0;">让旅行更有温度</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px;">
|
||||
<h2 style="color: #2c3e50; margin-bottom: 20px;">密码重置</h2>
|
||||
<p style="color: #555; margin-bottom: 20px;">
|
||||
您请求重置密码,请点击下面的按钮重置您的密码:
|
||||
</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${resetUrl}"
|
||||
style="background: #3498db; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
|
||||
重置密码
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #7f8c8d; font-size: 14px; margin-bottom: 10px;">
|
||||
如果按钮无法点击,请复制以下链接到浏览器地址栏:
|
||||
</p>
|
||||
<p style="background: #fff; padding: 10px; border-radius: 4px; word-break: break-all; font-size: 12px; color: #555;">
|
||||
${resetUrl}
|
||||
</p>
|
||||
<p style="color: #e74c3c; font-size: 14px; margin-top: 20px;">
|
||||
此链接将在 ${expiresInMinutes} 分钟后过期。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; text-align: center;">
|
||||
<p style="color: #7f8c8d; font-size: 12px; margin: 0;">
|
||||
如果这不是您的操作,请忽略此邮件。您的密码不会被更改。<br>
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return this.sendEmail({ to, subject, html });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送欢迎邮件
|
||||
* @param {string} to - 收件人邮箱
|
||||
* @param {string} username - 用户名
|
||||
*/
|
||||
async sendWelcomeEmail(to, username) {
|
||||
const subject = '欢迎加入结伴客!';
|
||||
const html = `
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="color: #2c3e50; margin: 0;">结伴客</h1>
|
||||
<p style="color: #7f8c8d; margin: 5px 0;">让旅行更有温度</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px;">
|
||||
<h2 style="color: #2c3e50; margin-bottom: 20px;">欢迎加入结伴客!</h2>
|
||||
<p style="color: #555; margin-bottom: 20px;">
|
||||
亲爱的 ${username},欢迎您加入结伴客大家庭!
|
||||
</p>
|
||||
<p style="color: #555; margin-bottom: 20px;">
|
||||
在这里,您可以:
|
||||
</p>
|
||||
<ul style="color: #555; margin-bottom: 20px; padding-left: 20px;">
|
||||
<li>发起或参加精彩的旅行活动</li>
|
||||
<li>认领可爱的小动物,体验农场生活</li>
|
||||
<li>结识志同道合的旅行伙伴</li>
|
||||
<li>享受专业的商家服务</li>
|
||||
</ul>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${process.env.FRONTEND_URL || 'https://jiebanke.com'}"
|
||||
style="background: #27ae60; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
|
||||
开始探索
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; text-align: center;">
|
||||
<p style="color: #7f8c8d; font-size: 12px; margin: 0;">
|
||||
感谢您选择结伴客,祝您使用愉快!<br>
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return this.sendEmail({ to, subject, html });
|
||||
}
|
||||
}
|
||||
|
||||
// 创建邮件服务实例
|
||||
const emailService = new EmailService();
|
||||
|
||||
// 导出便捷方法
|
||||
const sendEmail = (options) => emailService.sendEmail(options);
|
||||
const sendVerificationCode = (to, code, expiresInMinutes) =>
|
||||
emailService.sendVerificationCode(to, code, expiresInMinutes);
|
||||
const sendPasswordReset = (to, resetUrl, expiresInMinutes) =>
|
||||
emailService.sendPasswordReset(to, resetUrl, expiresInMinutes);
|
||||
const sendWelcomeEmail = (to, username) =>
|
||||
emailService.sendWelcomeEmail(to, username);
|
||||
|
||||
module.exports = {
|
||||
EmailService,
|
||||
emailService,
|
||||
sendEmail,
|
||||
sendVerificationCode,
|
||||
sendPasswordReset,
|
||||
sendWelcomeEmail
|
||||
};
|
||||
347
backend/src/utils/logger.js
Normal file
347
backend/src/utils/logger.js
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 日志工具模块
|
||||
* 提供统一的日志记录功能,支持不同级别的日志输出
|
||||
*/
|
||||
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// 确保日志目录存在
|
||||
const logDir = path.join(__dirname, '../../logs');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义日志格式
|
||||
*/
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json(),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
let logMessage = `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
||||
|
||||
// 如果有额外的元数据,添加到日志中
|
||||
if (Object.keys(meta).length > 0) {
|
||||
logMessage += `\n${JSON.stringify(meta, null, 2)}`;
|
||||
}
|
||||
|
||||
return logMessage;
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* 创建Winston日志器
|
||||
*/
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
defaultMeta: { service: 'jiebanke-api' },
|
||||
transports: [
|
||||
// 错误日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
)
|
||||
}),
|
||||
|
||||
// 组合日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'combined.log'),
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
)
|
||||
}),
|
||||
|
||||
// 访问日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'access.log'),
|
||||
level: 'http',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
)
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// 开发环境下添加控制台输出
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple(),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
let logMessage = `${timestamp} [${level}]: ${message}`;
|
||||
|
||||
// 如果有额外的元数据,添加到日志中
|
||||
if (Object.keys(meta).length > 0) {
|
||||
logMessage += `\n${JSON.stringify(meta, null, 2)}`;
|
||||
}
|
||||
|
||||
return logMessage;
|
||||
})
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求日志中间件
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
const requestLogger = (req, res, next) => {
|
||||
const start = Date.now();
|
||||
|
||||
// 记录请求开始
|
||||
logger.http('Request started', {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
userId: req.user?.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 监听响应结束
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const logLevel = res.statusCode >= 400 ? 'warn' : 'http';
|
||||
|
||||
logger.log(logLevel, 'Request completed', {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
userId: req.user?.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据库操作日志
|
||||
* @param {string} operation - 操作类型
|
||||
* @param {string} table - 表名
|
||||
* @param {Object} data - 操作数据
|
||||
* @param {Object} user - 操作用户
|
||||
*/
|
||||
const logDatabaseOperation = (operation, table, data = {}, user = null) => {
|
||||
logger.info('Database operation', {
|
||||
operation,
|
||||
table,
|
||||
data: JSON.stringify(data),
|
||||
userId: user?.id,
|
||||
userType: user?.user_type,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 业务操作日志
|
||||
* @param {string} action - 操作动作
|
||||
* @param {string} resource - 资源类型
|
||||
* @param {Object} details - 操作详情
|
||||
* @param {Object} user - 操作用户
|
||||
*/
|
||||
const logBusinessOperation = (action, resource, details = {}, user = null) => {
|
||||
logger.info('Business operation', {
|
||||
action,
|
||||
resource,
|
||||
details: JSON.stringify(details),
|
||||
userId: user?.id,
|
||||
userType: user?.user_type,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全事件日志
|
||||
* @param {string} event - 事件类型
|
||||
* @param {Object} details - 事件详情
|
||||
* @param {Object} req - 请求对象
|
||||
*/
|
||||
const logSecurityEvent = (event, details = {}, req = null) => {
|
||||
logger.warn('Security event', {
|
||||
event,
|
||||
details: JSON.stringify(details),
|
||||
ip: req?.ip,
|
||||
userAgent: req?.get('User-Agent'),
|
||||
userId: req?.user?.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 性能监控日志
|
||||
* @param {string} operation - 操作名称
|
||||
* @param {number} duration - 执行时间(毫秒)
|
||||
* @param {Object} metadata - 额外元数据
|
||||
*/
|
||||
const logPerformance = (operation, duration, metadata = {}) => {
|
||||
const level = duration > 1000 ? 'warn' : 'info';
|
||||
|
||||
logger.log(level, 'Performance monitoring', {
|
||||
operation,
|
||||
duration: `${duration}ms`,
|
||||
...metadata,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 系统事件日志
|
||||
* @param {string} event - 事件类型
|
||||
* @param {Object} details - 事件详情
|
||||
*/
|
||||
const logSystemEvent = (event, details = {}) => {
|
||||
logger.info('System event', {
|
||||
event,
|
||||
details: JSON.stringify(details),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 错误日志(带上下文)
|
||||
* @param {Error} error - 错误对象
|
||||
* @param {Object} context - 错误上下文
|
||||
*/
|
||||
const logError = (error, context = {}) => {
|
||||
logger.error('Application error', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
context: JSON.stringify(context),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 调试日志
|
||||
* @param {string} message - 调试信息
|
||||
* @param {Object} data - 调试数据
|
||||
*/
|
||||
const logDebug = (message, data = {}) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.debug(message, {
|
||||
data: JSON.stringify(data),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 日志清理任务
|
||||
* 定期清理过期的日志文件
|
||||
*/
|
||||
const cleanupLogs = () => {
|
||||
const maxAge = 30 * 24 * 60 * 60 * 1000; // 30天
|
||||
const now = Date.now();
|
||||
|
||||
fs.readdir(logDir, (err, files) => {
|
||||
if (err) {
|
||||
logger.error('Failed to read log directory', { error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(logDir, file);
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
if (err) return;
|
||||
|
||||
if (now - stats.mtime.getTime() > maxAge) {
|
||||
fs.unlink(filePath, (err) => {
|
||||
if (err) {
|
||||
logger.error('Failed to delete old log file', {
|
||||
file: filePath,
|
||||
error: err.message
|
||||
});
|
||||
} else {
|
||||
logger.info('Deleted old log file', { file: filePath });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 每天执行一次日志清理
|
||||
setInterval(cleanupLogs, 24 * 60 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* 日志统计信息
|
||||
*/
|
||||
const getLogStats = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readdir(logDir, (err, files) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalFiles: files.length,
|
||||
files: []
|
||||
};
|
||||
|
||||
let processed = 0;
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(logDir, file);
|
||||
fs.stat(filePath, (err, fileStat) => {
|
||||
if (!err) {
|
||||
stats.files.push({
|
||||
name: file,
|
||||
size: fileStat.size,
|
||||
created: fileStat.birthtime,
|
||||
modified: fileStat.mtime
|
||||
});
|
||||
}
|
||||
|
||||
processed++;
|
||||
if (processed === files.length) {
|
||||
resolve(stats);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
resolve(stats);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
logger,
|
||||
requestLogger,
|
||||
logDatabaseOperation,
|
||||
logBusinessOperation,
|
||||
logSecurityEvent,
|
||||
logPerformance,
|
||||
logSystemEvent,
|
||||
logError,
|
||||
logDebug,
|
||||
cleanupLogs,
|
||||
getLogStats
|
||||
};
|
||||
1659
docs/前端开发文档.md
Normal file
1659
docs/前端开发文档.md
Normal file
File diff suppressed because it is too large
Load Diff
478
docs/动物认领系统API文档.md
Normal file
478
docs/动物认领系统API文档.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# 动物认领系统API文档
|
||||
|
||||
## 概述
|
||||
|
||||
动物认领系统提供了完整的动物认领申请、审核、管理功能,支持用户申请认领动物、管理员审核申请、认领续期等功能。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- **基础URL**: `/api/v1/animal-claims`
|
||||
- **认证方式**: Bearer Token
|
||||
- **数据格式**: JSON
|
||||
- **字符编码**: UTF-8
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 认领申请 (AnimalClaim)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"claim_no": "CLAIM20241201001",
|
||||
"animal_id": 1,
|
||||
"animal_name": "小白",
|
||||
"animal_type": "狗",
|
||||
"animal_image": "/uploads/animals/dog1.jpg",
|
||||
"user_id": 2,
|
||||
"username": "张三",
|
||||
"user_phone": "13800138001",
|
||||
"claim_reason": "我很喜欢这只小狗",
|
||||
"claim_duration": 12,
|
||||
"total_amount": 1200.00,
|
||||
"contact_info": "手机:13800138001,微信:user001",
|
||||
"status": "pending",
|
||||
"start_date": "2024-12-01T11:30:00.000Z",
|
||||
"end_date": "2025-12-01T11:30:00.000Z",
|
||||
"reviewed_by": 1,
|
||||
"reviewer_name": "管理员",
|
||||
"review_remark": "申请材料完整,同意认领",
|
||||
"reviewed_at": "2024-12-01T11:30:00.000Z",
|
||||
"approved_at": "2024-12-01T11:30:00.000Z",
|
||||
"cancelled_at": null,
|
||||
"cancel_reason": null,
|
||||
"created_at": "2024-12-01T10:00:00.000Z",
|
||||
"updated_at": "2024-12-01T11:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 认领统计 (ClaimStatistics)
|
||||
|
||||
```json
|
||||
{
|
||||
"basic": {
|
||||
"total_claims": 100,
|
||||
"pending_claims": 10,
|
||||
"approved_claims": 80,
|
||||
"rejected_claims": 8,
|
||||
"cancelled_claims": 2,
|
||||
"total_amount": 120000.00,
|
||||
"avg_duration": 12.5
|
||||
},
|
||||
"by_type": [
|
||||
{
|
||||
"type": "狗",
|
||||
"claim_count": 50,
|
||||
"approved_count": 45,
|
||||
"total_amount": 60000.00
|
||||
}
|
||||
],
|
||||
"by_month": [
|
||||
{
|
||||
"month": "2024-12",
|
||||
"claim_count": 20,
|
||||
"approved_count": 18,
|
||||
"total_amount": 24000.00
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
### 1. 申请认领动物
|
||||
|
||||
**接口地址**: `POST /api/v1/animal-claims`
|
||||
|
||||
**请求头**:
|
||||
```
|
||||
Authorization: Bearer {token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"animal_id": 1,
|
||||
"claim_reason": "我很喜欢这只小狗,希望能够认领它",
|
||||
"claim_duration": 12,
|
||||
"contact_info": "手机:13800138001,微信:user001"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
- `animal_id` (必填): 动物ID
|
||||
- `claim_reason` (可选): 认领理由
|
||||
- `claim_duration` (可选): 认领时长(月),默认12个月,范围1-60
|
||||
- `contact_info` (必填): 联系方式
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "认领申请提交成功",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"claim_no": "CLAIM20241201001",
|
||||
"animal_id": 1,
|
||||
"user_id": 2,
|
||||
"status": "pending",
|
||||
"total_amount": 1200.00
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取我的认领申请列表
|
||||
|
||||
**接口地址**: `GET /api/v1/animal-claims/my`
|
||||
|
||||
**请求参数**:
|
||||
- `page` (可选): 页码,默认1
|
||||
- `limit` (可选): 每页数量,默认10,最大100
|
||||
- `status` (可选): 申请状态 (pending/approved/rejected/cancelled)
|
||||
- `animal_type` (可选): 动物类型
|
||||
- `start_date` (可选): 开始日期 (YYYY-MM-DD)
|
||||
- `end_date` (可选): 结束日期 (YYYY-MM-DD)
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "获取认领申请列表成功",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"claim_no": "CLAIM20241201001",
|
||||
"animal_name": "小白",
|
||||
"status": "pending"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"total": 1,
|
||||
"pages": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 取消认领申请
|
||||
|
||||
**接口地址**: `PUT /api/v1/animal-claims/{id}/cancel`
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 认领申请ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "认领申请已取消",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"status": "cancelled",
|
||||
"cancelled_at": "2024-12-01T15:00:00.000Z",
|
||||
"cancel_reason": "用户主动取消"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 审核认领申请(管理员)
|
||||
|
||||
**接口地址**: `PUT /api/v1/animal-claims/{id}/review`
|
||||
|
||||
**权限要求**: 管理员或经理
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"status": "approved",
|
||||
"review_remark": "申请材料完整,同意认领"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
- `status` (必填): 审核状态 (approved/rejected)
|
||||
- `review_remark` (可选): 审核备注
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "认领申请审核通过",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"status": "approved",
|
||||
"reviewed_at": "2024-12-01T11:30:00.000Z",
|
||||
"start_date": "2024-12-01T11:30:00.000Z",
|
||||
"end_date": "2025-12-01T11:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 获取所有认领申请列表(管理员)
|
||||
|
||||
**接口地址**: `GET /api/v1/animal-claims`
|
||||
|
||||
**权限要求**: 管理员或经理
|
||||
|
||||
**请求参数**:
|
||||
- `page` (可选): 页码,默认1
|
||||
- `limit` (可选): 每页数量,默认10,最大100
|
||||
- `status` (可选): 申请状态
|
||||
- `animal_type` (可选): 动物类型
|
||||
- `user_id` (可选): 用户ID
|
||||
- `start_date` (可选): 开始日期
|
||||
- `end_date` (可选): 结束日期
|
||||
- `keyword` (可选): 关键词搜索(订单号、动物名称、用户名)
|
||||
|
||||
### 6. 获取动物的认领申请列表(管理员)
|
||||
|
||||
**接口地址**: `GET /api/v1/animal-claims/animal/{animal_id}`
|
||||
|
||||
**权限要求**: 管理员或经理
|
||||
|
||||
**路径参数**:
|
||||
- `animal_id`: 动物ID
|
||||
|
||||
### 7. 检查认领权限
|
||||
|
||||
**接口地址**: `GET /api/v1/animal-claims/check-permission/{animal_id}`
|
||||
|
||||
**路径参数**:
|
||||
- `animal_id`: 动物ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "检查认领权限成功",
|
||||
"data": {
|
||||
"can_claim": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 续期认领
|
||||
|
||||
**接口地址**: `POST /api/v1/animal-claims/{id}/renew`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"duration": 6,
|
||||
"payment_method": "wechat"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
- `duration` (必填): 续期时长(月),范围1-60
|
||||
- `payment_method` (必填): 支付方式 (wechat/alipay/bank_transfer)
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "续期申请已提交,请完成支付",
|
||||
"data": {
|
||||
"renewal": {
|
||||
"id": 1,
|
||||
"claim_id": 1,
|
||||
"duration": 6,
|
||||
"amount": 600.00,
|
||||
"status": "pending"
|
||||
},
|
||||
"amount": 600.00,
|
||||
"message": "续期申请已提交,请完成支付"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9. 获取认领统计信息(管理员)
|
||||
|
||||
**接口地址**: `GET /api/v1/animal-claims/statistics`
|
||||
|
||||
**权限要求**: 管理员或经理
|
||||
|
||||
**请求参数**:
|
||||
- `start_date` (可选): 开始日期
|
||||
- `end_date` (可选): 结束日期
|
||||
- `animal_type` (可选): 动物类型
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "获取认领统计信息成功",
|
||||
"data": {
|
||||
"basic": {
|
||||
"total_claims": 100,
|
||||
"pending_claims": 10,
|
||||
"approved_claims": 80,
|
||||
"rejected_claims": 8,
|
||||
"cancelled_claims": 2,
|
||||
"total_amount": 120000.00,
|
||||
"avg_duration": 12.5
|
||||
},
|
||||
"by_type": [...],
|
||||
"by_month": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 状态说明
|
||||
|
||||
### 认领申请状态
|
||||
|
||||
- `pending`: 待审核
|
||||
- `approved`: 已通过
|
||||
- `rejected`: 已拒绝
|
||||
- `cancelled`: 已取消
|
||||
- `expired`: 已过期(系统自动设置)
|
||||
|
||||
### 续期状态
|
||||
|
||||
- `pending`: 待支付
|
||||
- `paid`: 已支付
|
||||
- `cancelled`: 已取消
|
||||
|
||||
## 支付方式
|
||||
|
||||
- `wechat`: 微信支付
|
||||
- `alipay`: 支付宝
|
||||
- `bank_transfer`: 银行转账
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权,需要登录 |
|
||||
| 403 | 权限不足 |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
| 503 | 服务不可用(无数据库模式) |
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 认领申请规则
|
||||
|
||||
1. 每个用户对同一动物只能有一个有效的认领申请
|
||||
2. 只有状态为"可认领"的动物才能被申请认领
|
||||
3. 认领时长范围为1-60个月
|
||||
4. 认领申请通过后,动物状态自动变为"已认领"
|
||||
|
||||
### 审核规则
|
||||
|
||||
1. 只有待审核状态的申请才能被审核
|
||||
2. 审核通过后自动设置开始和结束时间
|
||||
3. 审核拒绝后动物状态保持不变
|
||||
|
||||
### 续期规则
|
||||
|
||||
1. 只有已通过的认领申请才能续期
|
||||
2. 距离到期30天内才能申请续期
|
||||
3. 续期需要完成支付才能生效
|
||||
|
||||
### 取消规则
|
||||
|
||||
1. 待审核和已通过的申请可以取消
|
||||
2. 取消已通过的申请会恢复动物为可认领状态
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 所有时间字段均为UTC时间,前端需要根据时区进行转换
|
||||
2. 金额字段为浮点数,建议前端使用专门的货币处理库
|
||||
3. 图片路径为相对路径,需要拼接完整的URL
|
||||
4. 分页查询建议设置合理的limit值,避免一次性查询过多数据
|
||||
5. 关键词搜索支持模糊匹配,会搜索订单号、动物名称、用户名
|
||||
|
||||
## 集成示例
|
||||
|
||||
### JavaScript示例
|
||||
|
||||
```javascript
|
||||
// 申请认领动物
|
||||
async function claimAnimal(animalId, claimData) {
|
||||
const response = await fetch('/api/v1/animal-claims', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
animal_id: animalId,
|
||||
...claimData
|
||||
})
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// 获取我的认领申请列表
|
||||
async function getMyClaimList(params = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const response = await fetch(`/api/v1/animal-claims/my?${queryString}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// 取消认领申请
|
||||
async function cancelClaim(claimId) {
|
||||
const response = await fetch(`/api/v1/animal-claims/${claimId}/cancel`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
```
|
||||
|
||||
### 前端状态管理示例
|
||||
|
||||
```javascript
|
||||
// 认领申请状态管理
|
||||
const claimStore = {
|
||||
state: {
|
||||
myClaimList: [],
|
||||
currentClaim: null,
|
||||
loading: false
|
||||
},
|
||||
|
||||
mutations: {
|
||||
SET_CLAIM_LIST(state, list) {
|
||||
state.myClaimList = list;
|
||||
},
|
||||
|
||||
SET_CURRENT_CLAIM(state, claim) {
|
||||
state.currentClaim = claim;
|
||||
},
|
||||
|
||||
UPDATE_CLAIM_STATUS(state, { claimId, status }) {
|
||||
const claim = state.myClaimList.find(c => c.id === claimId);
|
||||
if (claim) {
|
||||
claim.status = status;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchMyClaimList({ commit }, params) {
|
||||
commit('SET_LOADING', true);
|
||||
try {
|
||||
const result = await getMyClaimList(params);
|
||||
if (result.success) {
|
||||
commit('SET_CLAIM_LIST', result.data);
|
||||
}
|
||||
} finally {
|
||||
commit('SET_LOADING', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
2515
docs/安全和权限管理文档.md
Normal file
2515
docs/安全和权限管理文档.md
Normal file
File diff suppressed because it is too large
Load Diff
862
docs/开发规范和最佳实践.md
Normal file
862
docs/开发规范和最佳实践.md
Normal file
@@ -0,0 +1,862 @@
|
||||
# 解班客项目开发规范和最佳实践
|
||||
|
||||
## 📋 文档概述
|
||||
|
||||
本文档制定了解班客项目的开发规范、编码标准和最佳实践,旨在提高代码质量、团队协作效率和项目可维护性。
|
||||
|
||||
### 文档目标
|
||||
- 建立统一的代码规范和编码标准
|
||||
- 规范开发流程和团队协作方式
|
||||
- 提高代码质量和可维护性
|
||||
- 确保项目的长期稳定发展
|
||||
|
||||
## 🎯 开发原则
|
||||
|
||||
### 核心原则
|
||||
1. **可读性优先**:代码应该易于理解和维护
|
||||
2. **一致性**:遵循统一的编码风格和命名规范
|
||||
3. **简洁性**:避免过度设计,保持代码简洁
|
||||
4. **可测试性**:编写易于测试的代码
|
||||
5. **安全性**:始终考虑安全因素
|
||||
6. **性能意识**:在保证功能的前提下优化性能
|
||||
|
||||
### SOLID原则
|
||||
- **S** - 单一职责原则(Single Responsibility Principle)
|
||||
- **O** - 开闭原则(Open/Closed Principle)
|
||||
- **L** - 里氏替换原则(Liskov Substitution Principle)
|
||||
- **I** - 接口隔离原则(Interface Segregation Principle)
|
||||
- **D** - 依赖倒置原则(Dependency Inversion Principle)
|
||||
|
||||
## 📁 项目结构规范
|
||||
|
||||
### 后端项目结构
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── controllers/ # 控制器层
|
||||
│ │ ├── admin/ # 管理员控制器
|
||||
│ │ └── user/ # 用户控制器
|
||||
│ ├── models/ # 数据模型层
|
||||
│ ├── routes/ # 路由层
|
||||
│ │ ├── admin/ # 管理员路由
|
||||
│ │ └── user/ # 用户路由
|
||||
│ ├── middleware/ # 中间件
|
||||
│ ├── services/ # 业务逻辑层
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── config/ # 配置文件
|
||||
│ └── validators/ # 数据验证
|
||||
├── tests/ # 测试文件
|
||||
│ ├── unit/ # 单元测试
|
||||
│ ├── integration/ # 集成测试
|
||||
│ └── fixtures/ # 测试数据
|
||||
├── docs/ # API文档
|
||||
├── scripts/ # 脚本文件
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### 前端项目结构
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/ # 公共组件
|
||||
│ │ ├── common/ # 通用组件
|
||||
│ │ └── business/ # 业务组件
|
||||
│ ├── views/ # 页面组件
|
||||
│ │ ├── admin/ # 管理员页面
|
||||
│ │ └── user/ # 用户页面
|
||||
│ ├── stores/ # Pinia状态管理
|
||||
│ ├── composables/ # 组合式函数
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── api/ # API接口
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── assets/ # 静态资源
|
||||
│ └── styles/ # 样式文件
|
||||
├── public/ # 公共资源
|
||||
├── tests/ # 测试文件
|
||||
└── package.json
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/ # 公共组件
|
||||
│ │ ├── common/ # 通用组件
|
||||
│ │ └── business/ # 业务组件
|
||||
│ ├── views/ # 页面视图
|
||||
│ │ ├── user/ # 用户相关页面
|
||||
│ │ ├── animal/ # 动物相关页面
|
||||
│ │ └── admin/ # 管理页面
|
||||
│ ├── stores/ # 状态管理
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── api/ # API接口
|
||||
│ ├── assets/ # 静态资源
|
||||
│ │ ├── images/ # 图片资源
|
||||
│ │ ├── styles/ # 样式文件
|
||||
│ │ └── icons/ # 图标资源
|
||||
│ └── composables/ # 组合式函数
|
||||
├── public/ # 公共文件
|
||||
├── tests/ # 测试文件
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🔤 命名规范
|
||||
|
||||
### 文件和目录命名
|
||||
- **文件名**: 使用小写字母和连字符 (`kebab-case`)
|
||||
```
|
||||
✅ user-management.js
|
||||
✅ animal-list.vue
|
||||
❌ UserManagement.js
|
||||
❌ animalList.vue
|
||||
```
|
||||
|
||||
- **目录名**: 使用小写字母和连字符
|
||||
```
|
||||
✅ user-management/
|
||||
✅ api-docs/
|
||||
❌ UserManagement/
|
||||
❌ apiDocs/
|
||||
```
|
||||
|
||||
### 变量和函数命名
|
||||
|
||||
#### JavaScript/Node.js
|
||||
- **变量**: 使用驼峰命名法 (`camelCase`)
|
||||
- **常量**: 使用大写字母和下划线 (`UPPER_SNAKE_CASE`)
|
||||
- **函数**: 使用驼峰命名法,动词开头
|
||||
- **类**: 使用帕斯卡命名法 (`PascalCase`)
|
||||
|
||||
```javascript
|
||||
// ✅ 正确示例
|
||||
const userName = 'john';
|
||||
const MAX_RETRY_COUNT = 3;
|
||||
const API_BASE_URL = 'https://api.example.com';
|
||||
|
||||
function getUserById(id) { }
|
||||
function createAnimalRecord(data) { }
|
||||
|
||||
class UserService { }
|
||||
class AnimalController { }
|
||||
|
||||
// ❌ 错误示例
|
||||
const user_name = 'john';
|
||||
const maxretrycount = 3;
|
||||
function GetUserById(id) { }
|
||||
class userService { }
|
||||
```
|
||||
|
||||
#### Vue.js组件
|
||||
- **组件名**: 使用帕斯卡命名法
|
||||
- **Props**: 使用驼峰命名法
|
||||
- **事件**: 使用kebab-case
|
||||
|
||||
```vue
|
||||
<!-- ✅ 正确示例 -->
|
||||
<template>
|
||||
<UserProfile
|
||||
:user-data="userData"
|
||||
@user-updated="handleUserUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'UserProfile',
|
||||
props: {
|
||||
userData: Object,
|
||||
isEditable: Boolean
|
||||
},
|
||||
emits: ['user-updated', 'profile-changed']
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 数据库命名
|
||||
- **表名**: 使用复数形式,下划线分隔
|
||||
- **字段名**: 使用下划线分隔
|
||||
- **索引名**: 使用 `idx_` 前缀
|
||||
- **外键名**: 使用 `fk_` 前缀
|
||||
|
||||
```sql
|
||||
-- ✅ 正确示例
|
||||
CREATE TABLE users (
|
||||
id INT PRIMARY KEY,
|
||||
user_name VARCHAR(50),
|
||||
email_address VARCHAR(100),
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_email ON users(email_address);
|
||||
ALTER TABLE adoptions ADD CONSTRAINT fk_adoptions_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
```
|
||||
|
||||
## 💻 代码风格规范
|
||||
|
||||
### JavaScript/Node.js代码规范
|
||||
|
||||
#### 基本格式
|
||||
```javascript
|
||||
// ✅ 使用2个空格缩进
|
||||
if (condition) {
|
||||
doSomething();
|
||||
}
|
||||
|
||||
// ✅ 使用单引号
|
||||
const message = 'Hello World';
|
||||
|
||||
// ✅ 对象和数组的格式
|
||||
const user = {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
email: 'john@example.com'
|
||||
};
|
||||
|
||||
const animals = [
|
||||
'dog',
|
||||
'cat',
|
||||
'bird'
|
||||
];
|
||||
|
||||
// ✅ 函数声明
|
||||
function calculateAge(birthDate) {
|
||||
const today = new Date();
|
||||
const birth = new Date(birthDate);
|
||||
return today.getFullYear() - birth.getFullYear();
|
||||
}
|
||||
|
||||
// ✅ 箭头函数
|
||||
const getFullName = (firstName, lastName) => `${firstName} ${lastName}`;
|
||||
```
|
||||
|
||||
#### 注释规范
|
||||
```javascript
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {boolean} options.includeProfile - 是否包含个人资料
|
||||
* @returns {Promise<Object>} 用户信息对象
|
||||
* @throws {Error} 当用户不存在时抛出错误
|
||||
*/
|
||||
async function getUserInfo(userId, options = {}) {
|
||||
// 验证用户ID
|
||||
if (!userId || typeof userId !== 'number') {
|
||||
throw new Error('Invalid user ID');
|
||||
}
|
||||
|
||||
// 查询用户基本信息
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// 如果需要包含个人资料
|
||||
if (options.includeProfile) {
|
||||
user.profile = await UserProfile.findByUserId(userId);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
#### 错误处理
|
||||
```javascript
|
||||
// ✅ 使用try-catch处理异步错误
|
||||
async function createUser(userData) {
|
||||
try {
|
||||
// 验证输入数据
|
||||
const validatedData = validateUserData(userData);
|
||||
|
||||
// 创建用户
|
||||
const user = await User.create(validatedData);
|
||||
|
||||
// 记录日志
|
||||
logger.info('User created successfully', { userId: user.id });
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
// 记录错误日志
|
||||
logger.error('Failed to create user', { error: error.message, userData });
|
||||
|
||||
// 重新抛出错误
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 使用自定义错误类
|
||||
class ValidationError extends Error {
|
||||
constructor(message, field) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Vue.js代码规范
|
||||
|
||||
#### 组件结构
|
||||
```vue
|
||||
<template>
|
||||
<!-- 模板内容 -->
|
||||
<div class="user-profile">
|
||||
<div class="user-profile__header">
|
||||
<h2 class="user-profile__title">{{ user.name }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="user-profile__content">
|
||||
<UserAvatar
|
||||
:src="user.avatar"
|
||||
:alt="user.name"
|
||||
@click="handleAvatarClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue';
|
||||
|
||||
export default {
|
||||
name: 'UserProfile',
|
||||
|
||||
components: {
|
||||
UserAvatar
|
||||
},
|
||||
|
||||
props: {
|
||||
userId: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['profile-updated', 'avatar-changed'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
// 响应式数据
|
||||
const user = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
// 计算属性
|
||||
const displayName = computed(() => {
|
||||
return user.value ? user.value.name : 'Unknown User';
|
||||
});
|
||||
|
||||
// 方法
|
||||
const loadUser = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
user.value = await userStore.fetchUser(props.userId);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
if (props.editable) {
|
||||
emit('avatar-changed');
|
||||
}
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadUser();
|
||||
});
|
||||
|
||||
// 返回模板需要的数据和方法
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
displayName,
|
||||
handleAvatarClick
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-profile {
|
||||
padding: 20px;
|
||||
|
||||
&__header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
#### CSS/SCSS规范
|
||||
```scss
|
||||
// ✅ 使用BEM命名规范
|
||||
.animal-card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&__status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
&--available {
|
||||
background-color: #e8f5e8;
|
||||
color: #2d8f2d;
|
||||
}
|
||||
|
||||
&--adopted {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 使用CSS变量
|
||||
:root {
|
||||
--primary-color: #007bff;
|
||||
--success-color: #28a745;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 测试规范
|
||||
|
||||
### 测试文件命名
|
||||
- 单元测试: `*.test.js` 或 `*.spec.js`
|
||||
- 集成测试: `*.integration.test.js`
|
||||
- E2E测试: `*.e2e.test.js`
|
||||
|
||||
### 测试结构
|
||||
```javascript
|
||||
// ✅ 测试文件示例
|
||||
describe('UserService', () => {
|
||||
let userService;
|
||||
let mockDatabase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDatabase = createMockDatabase();
|
||||
userService = new UserService(mockDatabase);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockDatabase.reset();
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
it('should create user with valid data', async () => {
|
||||
// Arrange
|
||||
const userData = {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com'
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await userService.createUser(userData);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeTruthy();
|
||||
expect(result.name).toBe(userData.name);
|
||||
expect(result.email).toBe(userData.email);
|
||||
});
|
||||
|
||||
it('should throw error with invalid email', async () => {
|
||||
// Arrange
|
||||
const userData = {
|
||||
name: 'John Doe',
|
||||
email: 'invalid-email'
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await expect(userService.createUser(userData))
|
||||
.rejects
|
||||
.toThrow('Invalid email format');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 测试覆盖率要求
|
||||
- **单元测试覆盖率**: ≥ 80%
|
||||
- **集成测试覆盖率**: ≥ 60%
|
||||
- **关键业务逻辑**: 100%
|
||||
|
||||
## 📝 文档规范
|
||||
|
||||
### API文档
|
||||
使用OpenAPI 3.0规范编写API文档:
|
||||
|
||||
```yaml
|
||||
# ✅ API文档示例
|
||||
paths:
|
||||
/api/v1/users/{id}:
|
||||
get:
|
||||
summary: 获取用户信息
|
||||
description: 根据用户ID获取用户详细信息
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: 用户ID
|
||||
responses:
|
||||
'200':
|
||||
description: 成功获取用户信息
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'404':
|
||||
description: 用户不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
```
|
||||
|
||||
### 代码注释
|
||||
```javascript
|
||||
/**
|
||||
* 动物认领服务类
|
||||
* 处理动物认领相关的业务逻辑
|
||||
*/
|
||||
class AdoptionService {
|
||||
/**
|
||||
* 创建认领申请
|
||||
* @param {Object} adoptionData - 认领申请数据
|
||||
* @param {number} adoptionData.userId - 申请人ID
|
||||
* @param {number} adoptionData.animalId - 动物ID
|
||||
* @param {string} adoptionData.reason - 认领原因
|
||||
* @param {Object} adoptionData.contact - 联系方式
|
||||
* @returns {Promise<Object>} 认领申请对象
|
||||
* @throws {ValidationError} 当数据验证失败时
|
||||
* @throws {BusinessError} 当业务规则验证失败时
|
||||
*
|
||||
* @example
|
||||
* const adoption = await adoptionService.createAdoption({
|
||||
* userId: 123,
|
||||
* animalId: 456,
|
||||
* reason: '我想给这只小狗一个温暖的家',
|
||||
* contact: { phone: '13800138000', address: '北京市朝阳区' }
|
||||
* });
|
||||
*/
|
||||
async createAdoption(adoptionData) {
|
||||
// 实现代码...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔒 安全规范
|
||||
|
||||
### 输入验证
|
||||
```javascript
|
||||
// ✅ 使用joi进行数据验证
|
||||
const Joi = require('joi');
|
||||
|
||||
const userSchema = Joi.object({
|
||||
name: Joi.string().min(2).max(50).required(),
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/).required(),
|
||||
phone: Joi.string().pattern(/^1[3-9]\d{9}$/).optional()
|
||||
});
|
||||
|
||||
// 验证用户输入
|
||||
const { error, value } = userSchema.validate(userData);
|
||||
if (error) {
|
||||
throw new ValidationError(error.details[0].message);
|
||||
}
|
||||
```
|
||||
|
||||
### SQL注入防护
|
||||
```javascript
|
||||
// ✅ 使用参数化查询
|
||||
const getUserById = async (id) => {
|
||||
const query = 'SELECT * FROM users WHERE id = ?';
|
||||
const result = await db.query(query, [id]);
|
||||
return result[0];
|
||||
};
|
||||
|
||||
// ❌ 避免字符串拼接
|
||||
const getUserById = async (id) => {
|
||||
const query = `SELECT * FROM users WHERE id = ${id}`; // 危险!
|
||||
const result = await db.query(query);
|
||||
return result[0];
|
||||
};
|
||||
```
|
||||
|
||||
### 敏感信息处理
|
||||
```javascript
|
||||
// ✅ 密码加密
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const hashPassword = async (password) => {
|
||||
const saltRounds = 12;
|
||||
return await bcrypt.hash(password, saltRounds);
|
||||
};
|
||||
|
||||
// ✅ 敏感信息过滤
|
||||
const sanitizeUser = (user) => {
|
||||
const { password, salt, ...safeUser } = user;
|
||||
return safeUser;
|
||||
};
|
||||
```
|
||||
|
||||
## 🚀 性能优化规范
|
||||
|
||||
### 数据库查询优化
|
||||
```javascript
|
||||
// ✅ 使用索引和限制查询
|
||||
const getAnimals = async (filters, pagination) => {
|
||||
const { page = 1, limit = 20 } = pagination;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const query = `
|
||||
SELECT a.*, u.name as owner_name
|
||||
FROM animals a
|
||||
LEFT JOIN users u ON a.owner_id = u.id
|
||||
WHERE a.status = ?
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
return await db.query(query, [filters.status, limit, offset]);
|
||||
};
|
||||
|
||||
// ✅ 使用缓存
|
||||
const Redis = require('redis');
|
||||
const redis = Redis.createClient();
|
||||
|
||||
const getCachedUser = async (userId) => {
|
||||
const cacheKey = `user:${userId}`;
|
||||
|
||||
// 尝试从缓存获取
|
||||
let user = await redis.get(cacheKey);
|
||||
if (user) {
|
||||
return JSON.parse(user);
|
||||
}
|
||||
|
||||
// 从数据库获取
|
||||
user = await User.findById(userId);
|
||||
|
||||
// 存入缓存,过期时间1小时
|
||||
await redis.setex(cacheKey, 3600, JSON.stringify(user));
|
||||
|
||||
return user;
|
||||
};
|
||||
```
|
||||
|
||||
### 前端性能优化
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ 使用v-show代替v-if进行频繁切换 -->
|
||||
<div v-show="isVisible" class="content">
|
||||
<!-- ✅ 使用key优化列表渲染 -->
|
||||
<div
|
||||
v-for="animal in animals"
|
||||
:key="animal.id"
|
||||
class="animal-item"
|
||||
>
|
||||
{{ animal.name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watchEffect } from 'vue';
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
// ✅ 使用computed缓存计算结果
|
||||
const expensiveValue = computed(() => {
|
||||
return animals.value.filter(animal => animal.status === 'available')
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
});
|
||||
|
||||
// ✅ 使用防抖处理搜索
|
||||
const searchTerm = ref('');
|
||||
const debouncedSearch = debounce((term) => {
|
||||
performSearch(term);
|
||||
}, 300);
|
||||
|
||||
watchEffect(() => {
|
||||
debouncedSearch(searchTerm.value);
|
||||
});
|
||||
|
||||
return {
|
||||
expensiveValue,
|
||||
searchTerm
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## 📋 Git工作流规范
|
||||
|
||||
### 分支命名
|
||||
- **主分支**: `main`
|
||||
- **开发分支**: `develop`
|
||||
- **功能分支**: `feature/功能名称`
|
||||
- **修复分支**: `fix/问题描述`
|
||||
- **发布分支**: `release/版本号`
|
||||
|
||||
### 提交信息规范
|
||||
使用Conventional Commits规范:
|
||||
|
||||
```bash
|
||||
# ✅ 正确的提交信息
|
||||
feat: 添加用户认证功能
|
||||
fix: 修复动物列表分页问题
|
||||
docs: 更新API文档
|
||||
style: 统一代码格式
|
||||
refactor: 重构用户服务层
|
||||
test: 添加用户注册测试用例
|
||||
chore: 更新依赖包版本
|
||||
|
||||
# 详细提交信息示例
|
||||
feat: 添加动物认领申请功能
|
||||
|
||||
- 实现认领申请表单
|
||||
- 添加申请状态跟踪
|
||||
- 集成邮件通知功能
|
||||
- 添加相关测试用例
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### 代码审查清单
|
||||
- [ ] 代码符合项目规范
|
||||
- [ ] 功能实现正确
|
||||
- [ ] 测试用例充分
|
||||
- [ ] 文档更新完整
|
||||
- [ ] 性能影响评估
|
||||
- [ ] 安全风险评估
|
||||
- [ ] 向后兼容性检查
|
||||
|
||||
## 🛠️ 开发工具配置
|
||||
|
||||
### ESLint配置
|
||||
```json
|
||||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"@vue/eslint-config-prettier"
|
||||
],
|
||||
"rules": {
|
||||
"indent": ["error", 2],
|
||||
"quotes": ["error", "single"],
|
||||
"semi": ["error", "always"],
|
||||
"no-console": "warn",
|
||||
"no-debugger": "error",
|
||||
"no-unused-vars": "error"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prettier配置
|
||||
```json
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 80,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
```
|
||||
|
||||
### VS Code配置
|
||||
```json
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"emmet.includeLanguages": {
|
||||
"vue": "html"
|
||||
},
|
||||
"files.associations": {
|
||||
"*.vue": "vue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📚 学习资源
|
||||
|
||||
### 官方文档
|
||||
- [Vue.js 官方文档](https://vuejs.org/)
|
||||
- [Node.js 官方文档](https://nodejs.org/)
|
||||
- [Express.js 官方文档](https://expressjs.com/)
|
||||
- [MySQL 官方文档](https://dev.mysql.com/doc/)
|
||||
|
||||
### 最佳实践
|
||||
- [JavaScript 最佳实践](https://github.com/airbnb/javascript)
|
||||
- [Vue.js 风格指南](https://vuejs.org/style-guide/)
|
||||
- [Node.js 最佳实践](https://github.com/goldbergyoni/nodebestpractices)
|
||||
|
||||
### 工具和库
|
||||
- [ESLint](https://eslint.org/) - 代码检查
|
||||
- [Prettier](https://prettier.io/) - 代码格式化
|
||||
- [Jest](https://jestjs.io/) - 测试框架
|
||||
- [Joi](https://joi.dev/) - 数据验证
|
||||
|
||||
## 🔄 规范更新
|
||||
|
||||
本规范会根据项目发展和团队反馈持续更新。如有建议或问题,请通过以下方式反馈:
|
||||
|
||||
1. 创建GitHub Issue
|
||||
2. 提交Pull Request
|
||||
3. 团队会议讨论
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0.0
|
||||
**最后更新**: 2024年1月15日
|
||||
**下次审查**: 2024年4月15日
|
||||
2526
docs/性能优化文档.md
Normal file
2526
docs/性能优化文档.md
Normal file
File diff suppressed because it is too large
Load Diff
423
docs/支付系统API文档.md
Normal file
423
docs/支付系统API文档.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# 支付系统API文档
|
||||
|
||||
## 概述
|
||||
|
||||
支付系统提供完整的支付流程管理,包括支付订单创建、状态查询、退款处理等功能。支持微信支付、支付宝支付和余额支付三种支付方式。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- **基础URL**: `/api/v1/payments`
|
||||
- **认证方式**: Bearer Token
|
||||
- **数据格式**: JSON
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 支付订单 (Payment)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"payment_no": "PAY202401010001",
|
||||
"order_id": 1,
|
||||
"user_id": 1,
|
||||
"amount": 299.00,
|
||||
"paid_amount": 299.00,
|
||||
"payment_method": "wechat",
|
||||
"status": "paid",
|
||||
"transaction_id": "wx_transaction_123",
|
||||
"paid_at": "2024-01-01T10:00:00Z",
|
||||
"created_at": "2024-01-01T09:00:00Z",
|
||||
"updated_at": "2024-01-01T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 退款记录 (Refund)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"refund_no": "REF202401010001",
|
||||
"payment_id": 1,
|
||||
"user_id": 1,
|
||||
"refund_amount": 100.00,
|
||||
"refund_reason": "用户申请退款",
|
||||
"status": "completed",
|
||||
"processed_by": 2,
|
||||
"process_remark": "同意退款",
|
||||
"processed_at": "2024-01-01T11:00:00Z",
|
||||
"refunded_at": "2024-01-01T11:30:00Z",
|
||||
"created_at": "2024-01-01T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
### 1. 创建支付订单
|
||||
|
||||
**接口**: `POST /api/v1/payments`
|
||||
|
||||
**描述**: 为订单创建支付订单
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"order_id": 1,
|
||||
"amount": 299.00,
|
||||
"payment_method": "wechat",
|
||||
"return_url": "https://example.com/success"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
- `order_id` (必填): 订单ID
|
||||
- `amount` (必填): 支付金额
|
||||
- `payment_method` (必填): 支付方式 (wechat/alipay/balance)
|
||||
- `return_url` (可选): 支付成功回调地址
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "支付订单创建成功",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"payment_no": "PAY202401010001",
|
||||
"order_id": 1,
|
||||
"amount": 299.00,
|
||||
"payment_method": "wechat",
|
||||
"status": "pending",
|
||||
"payment_params": {
|
||||
"prepay_id": "wx_prepay_123",
|
||||
"code_url": "weixin://wxpay/bizpayurl?pr=abc123"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取支付订单详情
|
||||
|
||||
**接口**: `GET /api/v1/payments/{paymentId}`
|
||||
|
||||
**描述**: 获取指定支付订单的详细信息
|
||||
|
||||
**路径参数**:
|
||||
- `paymentId`: 支付订单ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"payment_no": "PAY202401010001",
|
||||
"order_id": 1,
|
||||
"user_id": 1,
|
||||
"amount": 299.00,
|
||||
"paid_amount": 299.00,
|
||||
"payment_method": "wechat",
|
||||
"status": "paid",
|
||||
"transaction_id": "wx_transaction_123",
|
||||
"paid_at": "2024-01-01T10:00:00Z",
|
||||
"order_no": "ORD202401010001",
|
||||
"username": "张三",
|
||||
"phone": "13800138000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 查询支付状态
|
||||
|
||||
**接口**: `GET /api/v1/payments/query/{paymentNo}`
|
||||
|
||||
**描述**: 根据支付订单号查询支付状态
|
||||
|
||||
**路径参数**:
|
||||
- `paymentNo`: 支付订单号
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"payment_no": "PAY202401010001",
|
||||
"status": "paid",
|
||||
"amount": 299.00,
|
||||
"paid_at": "2024-01-01T10:00:00Z",
|
||||
"transaction_id": "wx_transaction_123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 支付回调接口
|
||||
|
||||
#### 微信支付回调
|
||||
|
||||
**接口**: `POST /api/v1/payments/callback/wechat`
|
||||
|
||||
**描述**: 微信支付异步通知接口
|
||||
|
||||
**请求格式**: XML
|
||||
|
||||
**响应格式**: XML
|
||||
|
||||
#### 支付宝回调
|
||||
|
||||
**接口**: `POST /api/v1/payments/callback/alipay`
|
||||
|
||||
**描述**: 支付宝异步通知接口
|
||||
|
||||
**请求格式**: Form Data
|
||||
|
||||
**响应格式**: 文本 (success/fail)
|
||||
|
||||
### 5. 申请退款
|
||||
|
||||
**接口**: `POST /api/v1/payments/{paymentId}/refund`
|
||||
|
||||
**描述**: 为已支付的订单申请退款
|
||||
|
||||
**路径参数**:
|
||||
- `paymentId`: 支付订单ID
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"refund_amount": 100.00,
|
||||
"refund_reason": "商品质量问题"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
- `refund_amount` (必填): 退款金额
|
||||
- `refund_reason` (必填): 退款原因
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "退款申请提交成功",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"refund_no": "REF202401010001",
|
||||
"payment_id": 1,
|
||||
"refund_amount": 100.00,
|
||||
"refund_reason": "商品质量问题",
|
||||
"status": "pending",
|
||||
"created_at": "2024-01-01T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 获取退款详情
|
||||
|
||||
**接口**: `GET /api/v1/payments/refunds/{refundId}`
|
||||
|
||||
**描述**: 获取退款记录详情
|
||||
|
||||
**路径参数**:
|
||||
- `refundId`: 退款ID
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"refund_no": "REF202401010001",
|
||||
"payment_id": 1,
|
||||
"refund_amount": 100.00,
|
||||
"refund_reason": "商品质量问题",
|
||||
"status": "completed",
|
||||
"processed_by": 2,
|
||||
"process_remark": "同意退款",
|
||||
"processed_at": "2024-01-01T11:00:00Z",
|
||||
"refunded_at": "2024-01-01T11:30:00Z",
|
||||
"payment_no": "PAY202401010001",
|
||||
"username": "张三",
|
||||
"processed_by_name": "管理员"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 处理退款(管理员)
|
||||
|
||||
**接口**: `PUT /api/v1/payments/refunds/{refundId}/process`
|
||||
|
||||
**描述**: 管理员处理退款申请
|
||||
|
||||
**权限**: 管理员
|
||||
|
||||
**路径参数**:
|
||||
- `refundId`: 退款ID
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"status": "approved",
|
||||
"process_remark": "同意退款申请"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
- `status` (必填): 退款状态 (approved/rejected/completed)
|
||||
- `process_remark` (可选): 处理备注
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "退款处理成功",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"refund_no": "REF202401010001",
|
||||
"status": "approved",
|
||||
"processed_by": 2,
|
||||
"process_remark": "同意退款申请",
|
||||
"processed_at": "2024-01-01T11:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 获取支付统计信息(管理员)
|
||||
|
||||
**接口**: `GET /api/v1/payments/statistics`
|
||||
|
||||
**描述**: 获取支付相关的统计信息
|
||||
|
||||
**权限**: 管理员
|
||||
|
||||
**查询参数**:
|
||||
- `start_date` (可选): 开始日期 (YYYY-MM-DD)
|
||||
- `end_date` (可选): 结束日期 (YYYY-MM-DD)
|
||||
- `payment_method` (可选): 支付方式
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total_count": 100,
|
||||
"total_amount": 29900.00,
|
||||
"success_count": 85,
|
||||
"success_amount": 25415.00,
|
||||
"refund_count": 5,
|
||||
"refund_amount": 1500.00,
|
||||
"method_stats": [
|
||||
{
|
||||
"payment_method": "wechat",
|
||||
"count": 50,
|
||||
"amount": 15000.00
|
||||
},
|
||||
{
|
||||
"payment_method": "alipay",
|
||||
"count": 35,
|
||||
"amount": 10415.00
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 状态说明
|
||||
|
||||
### 支付状态 (Payment Status)
|
||||
|
||||
- `pending`: 待支付
|
||||
- `paid`: 已支付
|
||||
- `failed`: 支付失败
|
||||
- `refunded`: 已退款
|
||||
- `cancelled`: 已取消
|
||||
|
||||
### 退款状态 (Refund Status)
|
||||
|
||||
- `pending`: 待处理
|
||||
- `approved`: 已同意
|
||||
- `rejected`: 已拒绝
|
||||
- `completed`: 已完成
|
||||
|
||||
## 支付方式
|
||||
|
||||
- `wechat`: 微信支付
|
||||
- `alipay`: 支付宝支付
|
||||
- `balance`: 余额支付
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| 400 | 参数错误 |
|
||||
| 401 | 未授权 |
|
||||
| 403 | 权限不足 |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **安全性**: 所有支付相关接口都需要用户认证
|
||||
2. **权限控制**: 用户只能操作自己的支付订单和退款记录
|
||||
3. **金额精度**: 所有金额字段保留两位小数
|
||||
4. **回调验证**: 支付回调需要验证签名确保安全性
|
||||
5. **幂等性**: 支付订单创建支持幂等性,避免重复创建
|
||||
6. **超时处理**: 待支付订单会在24小时后自动取消
|
||||
|
||||
## 集成示例
|
||||
|
||||
### 创建支付订单示例
|
||||
|
||||
```javascript
|
||||
// 创建支付订单
|
||||
const createPayment = async (orderData) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/payments', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
order_id: orderData.orderId,
|
||||
amount: orderData.amount,
|
||||
payment_method: 'wechat',
|
||||
return_url: 'https://example.com/success'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// 跳转到支付页面或调用支付SDK
|
||||
handlePayment(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建支付订单失败:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 查询支付状态示例
|
||||
|
||||
```javascript
|
||||
// 轮询查询支付状态
|
||||
const checkPaymentStatus = async (paymentNo) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/payments/query/${paymentNo}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const status = result.data.status;
|
||||
if (status === 'paid') {
|
||||
// 支付成功处理
|
||||
handlePaymentSuccess();
|
||||
} else if (status === 'failed') {
|
||||
// 支付失败处理
|
||||
handlePaymentFailed();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询支付状态失败:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
694
docs/文件上传系统文档.md
Normal file
694
docs/文件上传系统文档.md
Normal file
@@ -0,0 +1,694 @@
|
||||
# 文件上传系统文档
|
||||
|
||||
## 概述
|
||||
|
||||
文件上传系统为解班客平台提供了完整的文件管理功能,支持多种文件类型的上传、处理、存储和管理。系统采用模块化设计,支持图片处理、文件验证、安全控制等功能。
|
||||
|
||||
## 系统架构
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **上传中间件** (`middleware/upload.js`)
|
||||
- 文件上传处理
|
||||
- 文件类型验证
|
||||
- 大小限制控制
|
||||
- 存储路径管理
|
||||
|
||||
2. **图片处理器**
|
||||
- 图片压缩和格式转换
|
||||
- 缩略图生成
|
||||
- 尺寸调整
|
||||
- 质量优化
|
||||
|
||||
3. **文件管理控制器** (`controllers/admin/fileManagement.js`)
|
||||
- 文件列表管理
|
||||
- 文件统计分析
|
||||
- 批量操作
|
||||
- 清理功能
|
||||
|
||||
4. **错误处理机制**
|
||||
- 统一错误响应
|
||||
- 详细错误日志
|
||||
- 安全错误信息
|
||||
|
||||
## 支持的文件类型
|
||||
|
||||
### 图片文件
|
||||
- **格式**: JPG, JPEG, PNG, GIF, WebP
|
||||
- **用途**: 头像、动物图片、旅行照片
|
||||
- **处理**: 自动压缩、生成缩略图、格式转换
|
||||
|
||||
### 文档文件
|
||||
- **格式**: PDF, DOC, DOCX, XLS, XLSX, TXT
|
||||
- **用途**: 证书、合同、报告等
|
||||
- **处理**: 文件验证、病毒扫描(计划中)
|
||||
|
||||
## 文件分类存储
|
||||
|
||||
### 存储目录结构
|
||||
```
|
||||
uploads/
|
||||
├── avatars/ # 用户头像
|
||||
├── animals/ # 动物图片
|
||||
├── travels/ # 旅行图片
|
||||
├── documents/ # 文档文件
|
||||
└── temp/ # 临时文件
|
||||
```
|
||||
|
||||
### 文件命名规则
|
||||
- **格式**: `{timestamp}_{randomString}.{extension}`
|
||||
- **示例**: `1701234567890_a1b2c3d4.jpg`
|
||||
- **优势**: 避免重名、便于排序、安全性高
|
||||
|
||||
## 上传限制配置
|
||||
|
||||
### 头像上传
|
||||
- **文件类型**: JPG, PNG
|
||||
- **文件大小**: 最大 2MB
|
||||
- **文件数量**: 1个
|
||||
- **处理**: 300x300像素,生成100x100缩略图
|
||||
|
||||
### 动物图片上传
|
||||
- **文件类型**: JPG, PNG, GIF, WebP
|
||||
- **文件大小**: 最大 5MB
|
||||
- **文件数量**: 最多 5张
|
||||
- **处理**: 800x600像素,生成200x200缩略图
|
||||
|
||||
### 旅行图片上传
|
||||
- **文件类型**: JPG, PNG, GIF, WebP
|
||||
- **文件大小**: 最大 5MB
|
||||
- **文件数量**: 最多 10张
|
||||
- **处理**: 1200x800像素,生成300x300缩略图
|
||||
|
||||
### 文档上传
|
||||
- **文件类型**: PDF, DOC, DOCX, XLS, XLSX, TXT
|
||||
- **文件大小**: 最大 10MB
|
||||
- **文件数量**: 最多 3个
|
||||
- **处理**: 仅验证,不做格式转换
|
||||
|
||||
## API接口说明
|
||||
|
||||
### 文件上传接口
|
||||
|
||||
#### 1. 头像上传
|
||||
```http
|
||||
POST /api/v1/admin/files/upload/avatar
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
avatar: [文件]
|
||||
```
|
||||
|
||||
#### 2. 动物图片上传
|
||||
```http
|
||||
POST /api/v1/admin/files/upload/animal
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
images: [文件1, 文件2, ...]
|
||||
```
|
||||
|
||||
#### 3. 旅行图片上传
|
||||
```http
|
||||
POST /api/v1/admin/files/upload/travel
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
images: [文件1, 文件2, ...]
|
||||
```
|
||||
|
||||
#### 4. 文档上传
|
||||
```http
|
||||
POST /api/v1/admin/files/upload/document
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
documents: [文件1, 文件2, ...]
|
||||
```
|
||||
|
||||
### 文件管理接口
|
||||
|
||||
#### 1. 获取文件列表
|
||||
```http
|
||||
GET /api/v1/admin/files?page=1&limit=20&type=all&keyword=搜索词
|
||||
```
|
||||
|
||||
#### 2. 获取文件详情
|
||||
```http
|
||||
GET /api/v1/admin/files/{file_id}
|
||||
```
|
||||
|
||||
#### 3. 删除文件
|
||||
```http
|
||||
DELETE /api/v1/admin/files/{file_id}
|
||||
```
|
||||
|
||||
#### 4. 批量删除文件
|
||||
```http
|
||||
POST /api/v1/admin/files/batch/delete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"file_ids": ["id1", "id2", "id3"]
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 获取文件统计
|
||||
```http
|
||||
GET /api/v1/admin/files/statistics
|
||||
```
|
||||
|
||||
#### 6. 清理无用文件
|
||||
```http
|
||||
POST /api/v1/admin/files/cleanup?dry_run=true
|
||||
```
|
||||
|
||||
## 图片处理功能
|
||||
|
||||
### 自动处理流程
|
||||
1. **上传验证**: 检查文件类型、大小、数量
|
||||
2. **格式转换**: 统一转换为JPEG格式(可配置)
|
||||
3. **尺寸调整**: 按预设尺寸调整图片大小
|
||||
4. **质量压缩**: 优化文件大小,保持视觉质量
|
||||
5. **缩略图生成**: 生成小尺寸预览图
|
||||
6. **文件保存**: 保存到指定目录
|
||||
|
||||
### 处理参数配置
|
||||
```javascript
|
||||
// 头像处理配置
|
||||
{
|
||||
width: 300,
|
||||
height: 300,
|
||||
quality: 85,
|
||||
format: 'jpeg',
|
||||
thumbnail: true,
|
||||
thumbnailSize: 100
|
||||
}
|
||||
|
||||
// 动物图片处理配置
|
||||
{
|
||||
width: 800,
|
||||
height: 600,
|
||||
quality: 80,
|
||||
format: 'jpeg',
|
||||
thumbnail: true,
|
||||
thumbnailSize: 200
|
||||
}
|
||||
```
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 文件验证
|
||||
1. **MIME类型检查**: 验证文件真实类型
|
||||
2. **文件扩展名检查**: 防止恶意文件上传
|
||||
3. **文件大小限制**: 防止大文件攻击
|
||||
4. **文件数量限制**: 防止批量上传攻击
|
||||
|
||||
### 存储安全
|
||||
1. **随机文件名**: 防止文件名猜测
|
||||
2. **目录隔离**: 不同类型文件分目录存储
|
||||
3. **访问控制**: 通过Web服务器配置访问权限
|
||||
4. **定期清理**: 自动清理临时和无用文件
|
||||
|
||||
### 错误处理
|
||||
1. **统一错误格式**: 标准化错误响应
|
||||
2. **详细日志记录**: 记录所有操作和错误
|
||||
3. **安全错误信息**: 不暴露系统内部信息
|
||||
4. **异常恢复**: 上传失败时自动清理临时文件
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 图片优化
|
||||
1. **智能压缩**: 根据图片内容调整压缩参数
|
||||
2. **格式选择**: 自动选择最优图片格式
|
||||
3. **渐进式JPEG**: 支持渐进式加载
|
||||
4. **WebP支持**: 现代浏览器使用WebP格式
|
||||
|
||||
### 存储优化
|
||||
1. **分目录存储**: 避免单目录文件过多
|
||||
2. **CDN集成**: 支持CDN加速(计划中)
|
||||
3. **缓存策略**: 合理设置HTTP缓存头
|
||||
4. **压缩传输**: 启用gzip压缩
|
||||
|
||||
### 并发处理
|
||||
1. **异步处理**: 图片处理使用异步操作
|
||||
2. **队列机制**: 大批量操作使用队列(计划中)
|
||||
3. **限流控制**: 防止并发上传过多
|
||||
4. **资源监控**: 监控CPU和内存使用
|
||||
|
||||
## 监控和统计
|
||||
|
||||
### 文件统计
|
||||
- **总文件数量**: 系统中所有文件的数量
|
||||
- **存储空间使用**: 各类型文件占用的存储空间
|
||||
- **文件格式分布**: 不同格式文件的数量和占比
|
||||
- **上传趋势**: 文件上传的时间趋势
|
||||
|
||||
### 性能监控
|
||||
- **上传成功率**: 文件上传的成功率统计
|
||||
- **处理时间**: 文件处理的平均时间
|
||||
- **错误率**: 各类错误的发生频率
|
||||
- **存储使用率**: 存储空间的使用情况
|
||||
|
||||
### 日志记录
|
||||
- **操作日志**: 记录所有文件操作
|
||||
- **错误日志**: 记录所有错误和异常
|
||||
- **性能日志**: 记录性能相关数据
|
||||
- **安全日志**: 记录安全相关事件
|
||||
|
||||
## 维护和管理
|
||||
|
||||
### 定期维护任务
|
||||
1. **清理临时文件**: 每小时清理超过24小时的临时文件
|
||||
2. **清理无用文件**: 定期扫描和清理不再使用的文件
|
||||
3. **日志轮转**: 定期归档和清理日志文件
|
||||
4. **存储空间监控**: 监控存储空间使用情况
|
||||
|
||||
### 备份策略
|
||||
1. **增量备份**: 每日增量备份新上传的文件
|
||||
2. **全量备份**: 每周全量备份所有文件
|
||||
3. **异地备份**: 重要文件异地备份(计划中)
|
||||
4. **恢复测试**: 定期测试备份恢复功能
|
||||
|
||||
### 故障处理
|
||||
1. **自动恢复**: 临时故障自动重试
|
||||
2. **降级服务**: 服务异常时提供基础功能
|
||||
3. **故障通知**: 严重故障及时通知管理员
|
||||
4. **快速恢复**: 提供快速故障恢复方案
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 前端上传示例
|
||||
|
||||
#### HTML表单上传
|
||||
```html
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
<input type="file" name="images" multiple accept="image/*">
|
||||
<button type="submit">上传</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
#### JavaScript上传
|
||||
```javascript
|
||||
async function uploadFiles(files, type = 'animal') {
|
||||
const formData = new FormData();
|
||||
|
||||
// 添加文件到表单数据
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('images', files[i]);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/files/upload/${type}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
console.log('上传成功:', result.data.files);
|
||||
return result.data.files;
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
const uploadedFiles = await uploadFiles(files, 'animal');
|
||||
// 处理上传成功的文件
|
||||
displayUploadedFiles(uploadedFiles);
|
||||
} catch (error) {
|
||||
// 处理上传错误
|
||||
showError(error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Vue.js组件示例
|
||||
```vue
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<div class="upload-area" @drop="handleDrop" @dragover.prevent>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="acceptTypes"
|
||||
@change="handleFileSelect"
|
||||
style="display: none"
|
||||
>
|
||||
<button @click="$refs.fileInput.click()">选择文件</button>
|
||||
<p>或拖拽文件到此处</p>
|
||||
</div>
|
||||
|
||||
<div v-if="uploading" class="upload-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{width: progress + '%'}"></div>
|
||||
</div>
|
||||
<p>上传中... {{ progress }}%</p>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadedFiles.length > 0" class="uploaded-files">
|
||||
<h3>已上传文件</h3>
|
||||
<div class="file-list">
|
||||
<div v-for="file in uploadedFiles" :key="file.id" class="file-item">
|
||||
<img v-if="file.thumbnailUrl" :src="file.thumbnailUrl" :alt="file.filename">
|
||||
<div class="file-info">
|
||||
<p class="filename">{{ file.originalName }}</p>
|
||||
<p class="filesize">{{ formatFileSize(file.size) }}</p>
|
||||
</div>
|
||||
<button @click="deleteFile(file.id)" class="delete-btn">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FileUpload',
|
||||
props: {
|
||||
uploadType: {
|
||||
type: String,
|
||||
default: 'animal'
|
||||
},
|
||||
maxFiles: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
uploadedFiles: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
acceptTypes() {
|
||||
const types = {
|
||||
avatar: 'image/jpeg,image/png',
|
||||
animal: 'image/jpeg,image/png,image/gif,image/webp',
|
||||
travel: 'image/jpeg,image/png,image/gif,image/webp',
|
||||
document: '.pdf,.doc,.docx,.xls,.xlsx,.txt'
|
||||
};
|
||||
return types[this.uploadType] || 'image/*';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
this.uploadFiles(files);
|
||||
},
|
||||
|
||||
handleDrop(event) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
this.uploadFiles(files);
|
||||
},
|
||||
|
||||
async uploadFiles(files) {
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (files.length > this.maxFiles) {
|
||||
this.$message.error(`最多只能上传${this.maxFiles}个文件`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
this.progress = 0;
|
||||
|
||||
const formData = new FormData();
|
||||
const fieldName = this.uploadType === 'avatar' ? 'avatar' :
|
||||
this.uploadType === 'document' ? 'documents' : 'images';
|
||||
|
||||
files.forEach(file => {
|
||||
formData.append(fieldName, file);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.$http.post(
|
||||
`/admin/files/upload/${this.uploadType}`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
this.progress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
this.uploadedFiles.push(...response.data.data.files);
|
||||
this.$message.success('文件上传成功');
|
||||
this.$emit('uploaded', response.data.data.files);
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传失败:', error);
|
||||
this.$message.error(error.message || '文件上传失败');
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
this.progress = 0;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteFile(fileId) {
|
||||
try {
|
||||
const response = await this.$http.delete(`/admin/files/${fileId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
this.uploadedFiles = this.uploadedFiles.filter(f => f.id !== fileId);
|
||||
this.$message.success('文件删除成功');
|
||||
this.$emit('deleted', fileId);
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
this.$message.error(error.message || '文件删除失败');
|
||||
}
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-upload {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #007bff;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.uploaded-files {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.file-item img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-weight: bold;
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
|
||||
.filesize {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 上传失败
|
||||
**问题**: 文件上传时返回错误
|
||||
**可能原因**:
|
||||
- 文件大小超出限制
|
||||
- 文件类型不支持
|
||||
- 服务器存储空间不足
|
||||
- 网络连接问题
|
||||
|
||||
**解决方案**:
|
||||
- 检查文件大小和类型
|
||||
- 确认服务器存储空间
|
||||
- 检查网络连接
|
||||
- 查看错误日志
|
||||
|
||||
#### 2. 图片处理失败
|
||||
**问题**: 图片上传成功但处理失败
|
||||
**可能原因**:
|
||||
- Sharp库未正确安装
|
||||
- 图片文件损坏
|
||||
- 服务器内存不足
|
||||
- 权限问题
|
||||
|
||||
**解决方案**:
|
||||
- 重新安装Sharp库
|
||||
- 检查图片文件完整性
|
||||
- 增加服务器内存
|
||||
- 检查文件权限
|
||||
|
||||
#### 3. 文件访问404
|
||||
**问题**: 上传的文件无法访问
|
||||
**可能原因**:
|
||||
- 静态文件服务未配置
|
||||
- 文件路径错误
|
||||
- 文件被误删
|
||||
- 权限设置问题
|
||||
|
||||
**解决方案**:
|
||||
- 配置静态文件服务
|
||||
- 检查文件路径
|
||||
- 恢复备份文件
|
||||
- 调整文件权限
|
||||
|
||||
### 调试方法
|
||||
|
||||
#### 1. 启用详细日志
|
||||
```javascript
|
||||
// 在环境变量中设置
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
#### 2. 检查上传目录权限
|
||||
```bash
|
||||
# 检查目录权限
|
||||
ls -la uploads/
|
||||
|
||||
# 设置正确权限
|
||||
chmod 755 uploads/
|
||||
chmod 644 uploads/*
|
||||
```
|
||||
|
||||
#### 3. 监控系统资源
|
||||
```bash
|
||||
# 监控磁盘空间
|
||||
df -h
|
||||
|
||||
# 监控内存使用
|
||||
free -m
|
||||
|
||||
# 监控进程
|
||||
ps aux | grep node
|
||||
```
|
||||
|
||||
## 扩展功能
|
||||
|
||||
### 计划中的功能
|
||||
1. **CDN集成**: 支持阿里云OSS、腾讯云COS等
|
||||
2. **病毒扫描**: 集成病毒扫描引擎
|
||||
3. **水印添加**: 自动为图片添加水印
|
||||
4. **智能裁剪**: AI驱动的智能图片裁剪
|
||||
5. **格式转换**: 支持更多图片格式转换
|
||||
6. **批量处理**: 支持批量图片处理
|
||||
7. **版本控制**: 文件版本管理
|
||||
8. **权限控制**: 细粒度的文件访问权限
|
||||
|
||||
### 集成建议
|
||||
1. **前端组件**: 开发可复用的上传组件
|
||||
2. **移动端适配**: 支持移动端文件上传
|
||||
3. **拖拽上传**: 实现拖拽上传功能
|
||||
4. **进度显示**: 显示上传进度和状态
|
||||
5. **预览功能**: 上传前预览文件
|
||||
6. **批量操作**: 支持批量选择和操作
|
||||
|
||||
## 总结
|
||||
|
||||
文件上传系统为解班客平台提供了完整、安全、高效的文件管理解决方案。通过模块化设计、完善的错误处理、详细的日志记录和性能优化,确保系统的稳定性和可维护性。
|
||||
|
||||
系统支持多种文件类型,提供了灵活的配置选项,能够满足不同场景的需求。同时,通过监控和统计功能,管理员可以实时了解系统状态,及时发现和解决问题。
|
||||
|
||||
未来将继续完善系统功能,增加更多高级特性,为用户提供更好的文件管理体验。
|
||||
1246
docs/测试文档.md
Normal file
1246
docs/测试文档.md
Normal file
File diff suppressed because it is too large
Load Diff
783
docs/管理员后台系统API文档.md
Normal file
783
docs/管理员后台系统API文档.md
Normal file
@@ -0,0 +1,783 @@
|
||||
# 管理员后台系统API文档
|
||||
|
||||
## 概述
|
||||
|
||||
管理员后台系统提供了完整的系统管理功能,包括用户管理、动物管理、数据统计、系统监控等功能,支持管理员对整个平台进行全面的管理和监控。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- **基础URL**: `/api/v1/admin`
|
||||
- **认证方式**: Bearer Token
|
||||
- **数据格式**: JSON
|
||||
- **字符编码**: UTF-8
|
||||
- **权限要求**: 管理员权限(admin 或 super_admin)
|
||||
|
||||
## 权限说明
|
||||
|
||||
### 角色类型
|
||||
- **super_admin**: 超级管理员,拥有所有权限
|
||||
- **admin**: 普通管理员,拥有大部分管理权限
|
||||
- **manager**: 部门经理,拥有部分管理权限
|
||||
|
||||
### 权限控制
|
||||
所有管理员接口都需要通过Bearer Token进行身份验证,并根据用户角色进行权限控制。
|
||||
|
||||
## 用户管理模块
|
||||
|
||||
### 1. 获取用户列表
|
||||
|
||||
**接口地址**: `GET /admin/users`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"keyword": "搜索关键词",
|
||||
"user_type": "farmer",
|
||||
"status": "active",
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-12-31",
|
||||
"sort_by": "created_at",
|
||||
"sort_order": "desc"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"users": [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
"phone": "13800138001",
|
||||
"user_type": "farmer",
|
||||
"status": "active",
|
||||
"level": "bronze",
|
||||
"points": 1200,
|
||||
"travel_count": 5,
|
||||
"claim_count": 2,
|
||||
"last_login_at": "2024-12-01T10:30:00.000Z",
|
||||
"created_at": "2024-01-15T08:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"per_page": 10,
|
||||
"total": 1000,
|
||||
"total_pages": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取用户详情
|
||||
|
||||
**接口地址**: `GET /admin/users/{user_id}`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
"phone": "13800138001",
|
||||
"user_type": "farmer",
|
||||
"status": "active",
|
||||
"level": "bronze",
|
||||
"points": 1200,
|
||||
"profile": {
|
||||
"real_name": "张三",
|
||||
"avatar": "/uploads/avatars/user1.jpg",
|
||||
"bio": "热爱农业的城市青年"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"travel_count": 5,
|
||||
"claim_count": 2,
|
||||
"order_count": 8,
|
||||
"total_spent": 2500.00
|
||||
},
|
||||
"recentActivities": [
|
||||
{
|
||||
"type": "travel_created",
|
||||
"description": "创建了新的旅行计划",
|
||||
"created_at": "2024-12-01T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 更新用户状态
|
||||
|
||||
**接口地址**: `PUT /admin/users/{user_id}/status`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"status": "suspended",
|
||||
"reason": "违反平台规定"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 批量更新用户状态
|
||||
|
||||
**接口地址**: `PUT /admin/users/batch/status`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"user_ids": [1, 2, 3],
|
||||
"status": "suspended",
|
||||
"reason": "批量处理违规用户"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 获取用户统计信息
|
||||
|
||||
**接口地址**: `GET /admin/users/statistics`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"totalStats": {
|
||||
"total_users": 10000,
|
||||
"active_users": 8500,
|
||||
"new_users_today": 50,
|
||||
"new_users_week": 300
|
||||
},
|
||||
"typeStats": [
|
||||
{
|
||||
"user_type": "farmer",
|
||||
"count": 6000,
|
||||
"percentage": 60.0
|
||||
},
|
||||
{
|
||||
"user_type": "merchant",
|
||||
"count": 4000,
|
||||
"percentage": 40.0
|
||||
}
|
||||
],
|
||||
"levelStats": [
|
||||
{
|
||||
"level": "bronze",
|
||||
"count": 5000,
|
||||
"avg_points": 800
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 导出用户数据
|
||||
|
||||
**接口地址**: `GET /admin/users/export`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"format": "csv",
|
||||
"user_type": "farmer",
|
||||
"status": "active",
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-12-31"
|
||||
}
|
||||
```
|
||||
|
||||
## 动物管理模块
|
||||
|
||||
### 1. 获取动物列表
|
||||
|
||||
**接口地址**: `GET /admin/animals`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"keyword": "小白",
|
||||
"species": "dog",
|
||||
"status": "available",
|
||||
"merchant_id": 1,
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-12-31",
|
||||
"sort_by": "created_at",
|
||||
"sort_order": "desc"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"animals": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "小白",
|
||||
"species": "dog",
|
||||
"breed": "金毛",
|
||||
"age": 12,
|
||||
"gender": "male",
|
||||
"price": 1200.00,
|
||||
"status": "available",
|
||||
"merchant_id": 1,
|
||||
"merchant_name": "阳光农场",
|
||||
"claim_count": 3,
|
||||
"created_at": "2024-01-15T08:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"per_page": 10,
|
||||
"total": 500,
|
||||
"total_pages": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取动物详情
|
||||
|
||||
**接口地址**: `GET /admin/animals/{animal_id}`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"animal": {
|
||||
"id": 1,
|
||||
"name": "小白",
|
||||
"species": "dog",
|
||||
"breed": "金毛",
|
||||
"age": 12,
|
||||
"gender": "male",
|
||||
"price": 1200.00,
|
||||
"status": "available",
|
||||
"description": "温顺可爱的金毛犬",
|
||||
"images": ["/uploads/animals/dog1.jpg"],
|
||||
"merchant_name": "阳光农场"
|
||||
},
|
||||
"claimStats": {
|
||||
"total_claims": 5,
|
||||
"pending_claims": 1,
|
||||
"approved_claims": 3,
|
||||
"rejected_claims": 1
|
||||
},
|
||||
"recentClaims": [
|
||||
{
|
||||
"id": 1,
|
||||
"user_name": "张三",
|
||||
"status": "approved",
|
||||
"created_at": "2024-12-01T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 更新动物状态
|
||||
|
||||
**接口地址**: `PUT /admin/animals/{animal_id}/status`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"status": "unavailable",
|
||||
"reason": "动物健康检查"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 获取动物统计信息
|
||||
|
||||
**接口地址**: `GET /admin/animals/statistics`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"totalStats": {
|
||||
"total_animals": 500,
|
||||
"available_animals": 300,
|
||||
"claimed_animals": 150,
|
||||
"total_claims": 800,
|
||||
"avg_price": 1500.00
|
||||
},
|
||||
"speciesStats": [
|
||||
{
|
||||
"species": "dog",
|
||||
"count": 200,
|
||||
"avg_price": 1200.00
|
||||
}
|
||||
],
|
||||
"monthlyTrend": [
|
||||
{
|
||||
"month": "2024-12",
|
||||
"new_animals": 20,
|
||||
"new_claims": 35
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 数据统计模块
|
||||
|
||||
### 1. 获取系统概览统计
|
||||
|
||||
**接口地址**: `GET /admin/statistics/overview`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"users": {
|
||||
"total_users": 10000,
|
||||
"active_users": 8500,
|
||||
"new_users_today": 50,
|
||||
"new_users_week": 300
|
||||
},
|
||||
"travels": {
|
||||
"total_travels": 2000,
|
||||
"published_travels": 1500,
|
||||
"new_travels_today": 10
|
||||
},
|
||||
"animals": {
|
||||
"total_animals": 500,
|
||||
"available_animals": 300,
|
||||
"claimed_animals": 150
|
||||
},
|
||||
"orders": {
|
||||
"total_orders": 5000,
|
||||
"completed_orders": 4500,
|
||||
"total_revenue": 500000.00
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取用户增长趋势
|
||||
|
||||
**接口地址**: `GET /admin/statistics/user-growth`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"period": "30d"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"period": "30d",
|
||||
"trendData": [
|
||||
{
|
||||
"date": "2024-12-01",
|
||||
"new_users": 25,
|
||||
"cumulative_users": 9975
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 获取业务数据统计
|
||||
|
||||
**接口地址**: `GET /admin/statistics/business`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"period": "30d"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"period": "30d",
|
||||
"travelStats": [
|
||||
{
|
||||
"date": "2024-12-01",
|
||||
"new_travels": 5,
|
||||
"published_travels": 4,
|
||||
"matched_travels": 3
|
||||
}
|
||||
],
|
||||
"claimStats": [
|
||||
{
|
||||
"date": "2024-12-01",
|
||||
"new_claims": 8,
|
||||
"approved_claims": 6,
|
||||
"rejected_claims": 1
|
||||
}
|
||||
],
|
||||
"orderStats": [
|
||||
{
|
||||
"date": "2024-12-01",
|
||||
"new_orders": 15,
|
||||
"completed_orders": 12,
|
||||
"daily_revenue": 2500.00
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 获取地域分布统计
|
||||
|
||||
**接口地址**: `GET /admin/statistics/geographic`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"userDistribution": [
|
||||
{
|
||||
"province": "北京市",
|
||||
"city": "北京市",
|
||||
"user_count": 1500
|
||||
}
|
||||
],
|
||||
"provinceStats": [
|
||||
{
|
||||
"province": "北京市",
|
||||
"user_count": 1500,
|
||||
"farmer_count": 900,
|
||||
"merchant_count": 600
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 获取用户行为分析
|
||||
|
||||
**接口地址**: `GET /admin/statistics/user-behavior`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"activityStats": [
|
||||
{
|
||||
"activity_level": "high",
|
||||
"user_count": 2000
|
||||
}
|
||||
],
|
||||
"levelDistribution": [
|
||||
{
|
||||
"level": "bronze",
|
||||
"user_count": 5000,
|
||||
"avg_points": 800,
|
||||
"avg_travel_count": 2.5,
|
||||
"avg_claim_count": 1.2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 获取收入统计
|
||||
|
||||
**接口地址**: `GET /admin/statistics/revenue`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"period": "30d"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"period": "30d",
|
||||
"revenueTrend": [
|
||||
{
|
||||
"date": "2024-12-01",
|
||||
"daily_revenue": 2500.00,
|
||||
"completed_orders": 12,
|
||||
"total_orders": 15
|
||||
}
|
||||
],
|
||||
"revenueSource": [
|
||||
{
|
||||
"order_type": "travel",
|
||||
"order_count": 800,
|
||||
"total_revenue": 120000.00,
|
||||
"avg_order_value": 150.00
|
||||
}
|
||||
],
|
||||
"paymentMethodStats": [
|
||||
{
|
||||
"payment_method": "wechat",
|
||||
"order_count": 3000,
|
||||
"total_amount": 300000.00
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 导出统计报告
|
||||
|
||||
**接口地址**: `GET /admin/statistics/export`
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"reportType": "overview",
|
||||
"period": "30d",
|
||||
"format": "csv"
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| 200 | 请求成功 |
|
||||
| 400 | 参数错误 |
|
||||
| 401 | 未授权,需要登录 |
|
||||
| 403 | 权限不足 |
|
||||
| 404 | 资源不存在 |
|
||||
| 422 | 参数验证失败 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
## 状态说明
|
||||
|
||||
### 用户状态
|
||||
- **active**: 正常状态
|
||||
- **suspended**: 已暂停
|
||||
- **banned**: 已封禁
|
||||
- **inactive**: 未激活
|
||||
|
||||
### 动物状态
|
||||
- **available**: 可认领
|
||||
- **claimed**: 已认领
|
||||
- **unavailable**: 不可认领
|
||||
|
||||
### 认领状态
|
||||
- **pending**: 待审核
|
||||
- **approved**: 已通过
|
||||
- **rejected**: 已拒绝
|
||||
- **cancelled**: 已取消
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 用户管理规则
|
||||
1. 只有超级管理员可以创建和删除管理员账户
|
||||
2. 普通管理员可以管理普通用户,但不能管理其他管理员
|
||||
3. 用户状态变更需要记录操作原因和操作人
|
||||
4. 批量操作有数量限制,单次最多处理100个用户
|
||||
|
||||
### 动物管理规则
|
||||
1. 动物状态变更需要记录操作原因
|
||||
2. 已有认领申请的动物不能直接删除
|
||||
3. 动物价格修改需要管理员审核
|
||||
4. 动物图片上传有格式和大小限制
|
||||
|
||||
### 数据统计规则
|
||||
1. 统计数据每小时更新一次
|
||||
2. 导出功能有频率限制,每个管理员每天最多导出10次
|
||||
3. 敏感数据需要特殊权限才能查看
|
||||
4. 历史数据保留期限为2年
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **权限控制**: 所有接口都需要管理员权限,请确保在请求头中包含有效的Bearer Token
|
||||
2. **参数验证**: 请求参数会进行严格验证,确保传入正确的数据类型和格式
|
||||
3. **频率限制**: 部分接口有频率限制,请合理控制请求频率
|
||||
4. **数据安全**: 敏感数据会进行脱敏处理,完整数据需要特殊权限
|
||||
5. **操作日志**: 所有管理操作都会记录日志,便于审计和追踪
|
||||
|
||||
## 集成示例
|
||||
|
||||
### JavaScript示例
|
||||
|
||||
```javascript
|
||||
// 获取用户列表
|
||||
async function getUserList(page = 1, limit = 10) {
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/users?' + new URLSearchParams({
|
||||
page,
|
||||
limit,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc'
|
||||
}), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
console.log('用户列表:', result.data.users);
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户状态
|
||||
async function updateUserStatus(userId, status, reason) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/users/${userId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
reason
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
console.log('用户状态更新成功');
|
||||
return result;
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新用户状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统统计数据
|
||||
async function getSystemOverview() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/statistics/overview', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
console.log('系统概览:', result.data);
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统统计失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Python示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
import json
|
||||
|
||||
class AdminAPI:
|
||||
def __init__(self, base_url, token):
|
||||
self.base_url = base_url
|
||||
self.headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def get_user_list(self, page=1, limit=10, **kwargs):
|
||||
"""获取用户列表"""
|
||||
params = {'page': page, 'limit': limit, **kwargs}
|
||||
response = requests.get(
|
||||
f'{self.base_url}/admin/users',
|
||||
headers=self.headers,
|
||||
params=params
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def update_user_status(self, user_id, status, reason=None):
|
||||
"""更新用户状态"""
|
||||
data = {'status': status}
|
||||
if reason:
|
||||
data['reason'] = reason
|
||||
|
||||
response = requests.put(
|
||||
f'{self.base_url}/admin/users/{user_id}/status',
|
||||
headers=self.headers,
|
||||
json=data
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def get_system_overview(self):
|
||||
"""获取系统概览"""
|
||||
response = requests.get(
|
||||
f'{self.base_url}/admin/statistics/overview',
|
||||
headers=self.headers
|
||||
)
|
||||
return response.json()
|
||||
|
||||
# 使用示例
|
||||
api = AdminAPI('https://api.example.com/api/v1', 'your_token_here')
|
||||
|
||||
# 获取用户列表
|
||||
users = api.get_user_list(page=1, limit=20, user_type='farmer')
|
||||
print(f"获取到 {len(users['data']['users'])} 个用户")
|
||||
|
||||
# 更新用户状态
|
||||
result = api.update_user_status(1, 'suspended', '违反平台规定')
|
||||
if result['success']:
|
||||
print("用户状态更新成功")
|
||||
|
||||
# 获取系统统计
|
||||
overview = api.get_system_overview()
|
||||
print(f"系统用户总数: {overview['data']['users']['total_users']}")
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2024-12-01)
|
||||
- 初始版本发布
|
||||
- 实现用户管理基础功能
|
||||
- 实现动物管理基础功能
|
||||
- 实现数据统计基础功能
|
||||
|
||||
### v1.1.0 (计划中)
|
||||
- 增加订单管理功能
|
||||
- 增加商家管理功能
|
||||
- 增加系统配置管理
|
||||
- 优化统计报表功能
|
||||
1969
docs/系统集成和部署文档.md
Normal file
1969
docs/系统集成和部署文档.md
Normal file
File diff suppressed because it is too large
Load Diff
1080
docs/部署和运维文档.md
Normal file
1080
docs/部署和运维文档.md
Normal file
File diff suppressed because it is too large
Load Diff
859
docs/错误处理和日志系统文档.md
Normal file
859
docs/错误处理和日志系统文档.md
Normal file
@@ -0,0 +1,859 @@
|
||||
# 错误处理和日志系统文档
|
||||
|
||||
## 概述
|
||||
|
||||
错误处理和日志系统是解班客平台的核心基础设施,提供统一的错误处理机制、完善的日志记录功能和系统监控能力。系统采用分层设计,支持多种错误类型处理、多级日志记录和实时监控。
|
||||
|
||||
## 系统架构
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **错误处理中间件** (`middleware/errorHandler.js`)
|
||||
- 全局错误捕获
|
||||
- 错误分类处理
|
||||
- 统一错误响应
|
||||
- 错误日志记录
|
||||
|
||||
2. **日志记录系统** (`utils/logger.js`)
|
||||
- 多级日志记录
|
||||
- 日志格式化
|
||||
- 日志轮转管理
|
||||
- 性能监控
|
||||
|
||||
3. **自定义错误类**
|
||||
- 业务错误定义
|
||||
- 错误码管理
|
||||
- 错误信息国际化
|
||||
- 错误堆栈追踪
|
||||
|
||||
## 错误处理机制
|
||||
|
||||
### 错误分类
|
||||
|
||||
#### 1. 业务错误 (Business Errors)
|
||||
- **用户认证错误**: 登录失败、token过期等
|
||||
- **权限错误**: 无权限访问、操作被拒绝等
|
||||
- **数据验证错误**: 参数格式错误、必填项缺失等
|
||||
- **业务逻辑错误**: 余额不足、状态不允许等
|
||||
|
||||
#### 2. 系统错误 (System Errors)
|
||||
- **数据库错误**: 连接失败、查询超时等
|
||||
- **网络错误**: 请求超时、连接中断等
|
||||
- **文件系统错误**: 文件不存在、权限不足等
|
||||
- **第三方服务错误**: API调用失败、服务不可用等
|
||||
|
||||
#### 3. 程序错误 (Programming Errors)
|
||||
- **语法错误**: 代码语法问题
|
||||
- **运行时错误**: 空指针、类型错误等
|
||||
- **内存错误**: 内存溢出、内存泄漏等
|
||||
- **配置错误**: 配置文件错误、环境变量缺失等
|
||||
|
||||
### 错误处理流程
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[请求开始] --> B[业务逻辑处理]
|
||||
B --> C{是否发生错误?}
|
||||
C -->|否| D[正常响应]
|
||||
C -->|是| E[错误捕获]
|
||||
E --> F[错误分类]
|
||||
F --> G[错误日志记录]
|
||||
G --> H[错误响应格式化]
|
||||
H --> I[返回错误响应]
|
||||
D --> J[请求结束]
|
||||
I --> J
|
||||
```
|
||||
|
||||
### 自定义错误类
|
||||
|
||||
#### AppError 类
|
||||
```javascript
|
||||
class AppError extends Error {
|
||||
constructor(message, statusCode, errorCode = null, isOperational = true) {
|
||||
super(message);
|
||||
|
||||
this.statusCode = statusCode;
|
||||
this.errorCode = errorCode;
|
||||
this.isOperational = isOperational;
|
||||
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
|
||||
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 错误类型定义
|
||||
```javascript
|
||||
const ErrorTypes = {
|
||||
// 认证相关错误
|
||||
AUTH_TOKEN_MISSING: { code: 'AUTH_001', message: '缺少认证令牌' },
|
||||
AUTH_TOKEN_INVALID: { code: 'AUTH_002', message: '无效的认证令牌' },
|
||||
AUTH_TOKEN_EXPIRED: { code: 'AUTH_003', message: '认证令牌已过期' },
|
||||
|
||||
// 权限相关错误
|
||||
PERMISSION_DENIED: { code: 'PERM_001', message: '权限不足' },
|
||||
RESOURCE_FORBIDDEN: { code: 'PERM_002', message: '资源访问被禁止' },
|
||||
|
||||
// 验证相关错误
|
||||
VALIDATION_FAILED: { code: 'VALID_001', message: '数据验证失败' },
|
||||
REQUIRED_FIELD_MISSING: { code: 'VALID_002', message: '必填字段缺失' },
|
||||
INVALID_FORMAT: { code: 'VALID_003', message: '数据格式无效' },
|
||||
|
||||
// 业务逻辑错误
|
||||
RESOURCE_NOT_FOUND: { code: 'BIZ_001', message: '资源不存在' },
|
||||
RESOURCE_ALREADY_EXISTS: { code: 'BIZ_002', message: '资源已存在' },
|
||||
OPERATION_NOT_ALLOWED: { code: 'BIZ_003', message: '操作不被允许' },
|
||||
|
||||
// 系统错误
|
||||
DATABASE_ERROR: { code: 'SYS_001', message: '数据库操作失败' },
|
||||
FILE_SYSTEM_ERROR: { code: 'SYS_002', message: '文件系统错误' },
|
||||
NETWORK_ERROR: { code: 'SYS_003', message: '网络连接错误' },
|
||||
|
||||
// 第三方服务错误
|
||||
THIRD_PARTY_SERVICE_ERROR: { code: 'EXT_001', message: '第三方服务错误' },
|
||||
API_RATE_LIMIT_EXCEEDED: { code: 'EXT_002', message: 'API调用频率超限' }
|
||||
};
|
||||
```
|
||||
|
||||
### 错误响应格式
|
||||
|
||||
#### 标准错误响应
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "AUTH_002",
|
||||
"message": "无效的认证令牌",
|
||||
"details": "Token signature verification failed",
|
||||
"timestamp": "2024-01-15T10:30:00.000Z",
|
||||
"path": "/api/v1/admin/users",
|
||||
"method": "GET",
|
||||
"requestId": "req_1234567890"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 验证错误响应
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALID_001",
|
||||
"message": "数据验证失败",
|
||||
"details": {
|
||||
"email": ["邮箱格式不正确"],
|
||||
"password": ["密码长度至少8位", "密码必须包含数字和字母"]
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00.000Z",
|
||||
"path": "/api/v1/auth/register",
|
||||
"method": "POST",
|
||||
"requestId": "req_1234567891"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 日志系统
|
||||
|
||||
### 日志级别
|
||||
|
||||
#### 1. ERROR (错误)
|
||||
- **用途**: 记录系统错误和异常
|
||||
- **示例**: 数据库连接失败、未捕获的异常
|
||||
- **处理**: 需要立即关注和处理
|
||||
|
||||
#### 2. WARN (警告)
|
||||
- **用途**: 记录潜在问题和警告信息
|
||||
- **示例**: 性能警告、配置问题
|
||||
- **处理**: 需要关注,但不影响系统运行
|
||||
|
||||
#### 3. INFO (信息)
|
||||
- **用途**: 记录重要的业务操作和系统状态
|
||||
- **示例**: 用户登录、重要配置变更
|
||||
- **处理**: 用于审计和监控
|
||||
|
||||
#### 4. HTTP (HTTP请求)
|
||||
- **用途**: 记录HTTP请求和响应信息
|
||||
- **示例**: API调用、响应时间
|
||||
- **处理**: 用于性能分析和调试
|
||||
|
||||
#### 5. DEBUG (调试)
|
||||
- **用途**: 记录详细的调试信息
|
||||
- **示例**: 变量值、执行流程
|
||||
- **处理**: 仅在开发环境使用
|
||||
|
||||
### 日志格式
|
||||
|
||||
#### 标准日志格式
|
||||
```
|
||||
[2024-01-15 10:30:00.123] [INFO] [USER_AUTH] 用户登录成功 - userId: 12345, ip: 192.168.1.100, userAgent: Mozilla/5.0...
|
||||
```
|
||||
|
||||
#### JSON格式日志
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T10:30:00.123Z",
|
||||
"level": "INFO",
|
||||
"category": "USER_AUTH",
|
||||
"message": "用户登录成功",
|
||||
"metadata": {
|
||||
"userId": 12345,
|
||||
"ip": "192.168.1.100",
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"requestId": "req_1234567890",
|
||||
"duration": 150
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 日志分类
|
||||
|
||||
#### 1. 请求日志 (Request Logs)
|
||||
```javascript
|
||||
// 记录HTTP请求信息
|
||||
logger.http('API请求', {
|
||||
method: 'POST',
|
||||
url: '/api/v1/users',
|
||||
ip: '192.168.1.100',
|
||||
userAgent: 'Mozilla/5.0...',
|
||||
requestId: 'req_1234567890',
|
||||
userId: 12345,
|
||||
duration: 150,
|
||||
statusCode: 200
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. 业务日志 (Business Logs)
|
||||
```javascript
|
||||
// 记录业务操作
|
||||
logger.business('用户注册', {
|
||||
action: 'USER_REGISTER',
|
||||
userId: 12345,
|
||||
email: 'user@example.com',
|
||||
ip: '192.168.1.100',
|
||||
success: true
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 安全日志 (Security Logs)
|
||||
```javascript
|
||||
// 记录安全事件
|
||||
logger.security('登录失败', {
|
||||
event: 'LOGIN_FAILED',
|
||||
email: 'user@example.com',
|
||||
ip: '192.168.1.100',
|
||||
reason: 'INVALID_PASSWORD',
|
||||
attempts: 3
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. 性能日志 (Performance Logs)
|
||||
```javascript
|
||||
// 记录性能数据
|
||||
logger.performance('数据库查询', {
|
||||
operation: 'SELECT',
|
||||
table: 'users',
|
||||
duration: 50,
|
||||
rowCount: 100,
|
||||
query: 'SELECT * FROM users WHERE status = ?'
|
||||
});
|
||||
```
|
||||
|
||||
#### 5. 系统日志 (System Logs)
|
||||
```javascript
|
||||
// 记录系统事件
|
||||
logger.system('服务启动', {
|
||||
event: 'SERVER_START',
|
||||
port: 3000,
|
||||
environment: 'production',
|
||||
version: '1.0.0'
|
||||
});
|
||||
```
|
||||
|
||||
### 日志存储和轮转
|
||||
|
||||
#### 日志文件结构
|
||||
```
|
||||
logs/
|
||||
├── app.log # 应用主日志
|
||||
├── error.log # 错误日志
|
||||
├── access.log # 访问日志
|
||||
├── security.log # 安全日志
|
||||
├── performance.log # 性能日志
|
||||
├── business.log # 业务日志
|
||||
└── archived/ # 归档日志
|
||||
├── app-2024-01-14.log
|
||||
├── error-2024-01-14.log
|
||||
└── ...
|
||||
```
|
||||
|
||||
#### 日志轮转配置
|
||||
```javascript
|
||||
const winston = require('winston');
|
||||
require('winston-daily-rotate-file');
|
||||
|
||||
const transport = new winston.transports.DailyRotateFile({
|
||||
filename: 'logs/app-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d'
|
||||
});
|
||||
```
|
||||
|
||||
## 监控和告警
|
||||
|
||||
### 错误监控
|
||||
|
||||
#### 1. 错误率监控
|
||||
- **指标**: 每分钟错误数量、错误率
|
||||
- **阈值**: 错误率超过5%触发告警
|
||||
- **处理**: 自动发送告警通知
|
||||
|
||||
#### 2. 响应时间监控
|
||||
- **指标**: 平均响应时间、95%分位数
|
||||
- **阈值**: 响应时间超过2秒触发告警
|
||||
- **处理**: 性能优化建议
|
||||
|
||||
#### 3. 系统资源监控
|
||||
- **指标**: CPU使用率、内存使用率、磁盘空间
|
||||
- **阈值**: 资源使用率超过80%触发告警
|
||||
- **处理**: 资源扩容建议
|
||||
|
||||
### 日志分析
|
||||
|
||||
#### 1. 实时日志分析
|
||||
```javascript
|
||||
// 实时错误统计
|
||||
const errorStats = {
|
||||
total: 0,
|
||||
byType: {},
|
||||
byEndpoint: {},
|
||||
recentErrors: []
|
||||
};
|
||||
|
||||
// 更新错误统计
|
||||
function updateErrorStats(error, req) {
|
||||
errorStats.total++;
|
||||
errorStats.byType[error.code] = (errorStats.byType[error.code] || 0) + 1;
|
||||
errorStats.byEndpoint[req.path] = (errorStats.byEndpoint[req.path] || 0) + 1;
|
||||
|
||||
errorStats.recentErrors.unshift({
|
||||
timestamp: new Date(),
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
|
||||
// 保持最近100个错误
|
||||
if (errorStats.recentErrors.length > 100) {
|
||||
errorStats.recentErrors.pop();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 日志聚合分析
|
||||
```javascript
|
||||
// 按时间段聚合日志
|
||||
function aggregateLogs(startTime, endTime) {
|
||||
return {
|
||||
totalRequests: 0,
|
||||
successRequests: 0,
|
||||
errorRequests: 0,
|
||||
averageResponseTime: 0,
|
||||
topEndpoints: [],
|
||||
topErrors: [],
|
||||
userActivity: {}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 告警机制
|
||||
|
||||
#### 1. 邮件告警
|
||||
```javascript
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
async function sendErrorAlert(error, context) {
|
||||
const transporter = nodemailer.createTransporter({
|
||||
// 邮件服务配置
|
||||
});
|
||||
|
||||
const mailOptions = {
|
||||
from: 'system@jiebanke.com',
|
||||
to: 'admin@jiebanke.com',
|
||||
subject: `[解班客] 系统错误告警 - ${error.code}`,
|
||||
html: `
|
||||
<h2>系统错误告警</h2>
|
||||
<p><strong>错误代码:</strong> ${error.code}</p>
|
||||
<p><strong>错误信息:</strong> ${error.message}</p>
|
||||
<p><strong>发生时间:</strong> ${new Date().toLocaleString()}</p>
|
||||
<p><strong>请求路径:</strong> ${context.path}</p>
|
||||
<p><strong>用户ID:</strong> ${context.userId || '未知'}</p>
|
||||
<p><strong>IP地址:</strong> ${context.ip}</p>
|
||||
<pre><strong>错误堆栈:</strong>\n${error.stack}</pre>
|
||||
`
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 钉钉/企业微信告警
|
||||
```javascript
|
||||
async function sendDingTalkAlert(error, context) {
|
||||
const webhook = process.env.DINGTALK_WEBHOOK;
|
||||
|
||||
const message = {
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
title: '系统错误告警',
|
||||
text: `
|
||||
### 系统错误告警
|
||||
- **错误代码**: ${error.code}
|
||||
- **错误信息**: ${error.message}
|
||||
- **发生时间**: ${new Date().toLocaleString()}
|
||||
- **请求路径**: ${context.path}
|
||||
- **用户ID**: ${context.userId || '未知'}
|
||||
- **IP地址**: ${context.ip}
|
||||
`
|
||||
}
|
||||
};
|
||||
|
||||
await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 日志性能优化
|
||||
|
||||
#### 1. 异步日志写入
|
||||
```javascript
|
||||
const winston = require('winston');
|
||||
|
||||
const logger = winston.createLogger({
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: 'logs/app.log',
|
||||
// 启用异步写入
|
||||
options: { flags: 'a' }
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. 日志缓冲
|
||||
```javascript
|
||||
class LogBuffer {
|
||||
constructor(flushInterval = 1000, maxBufferSize = 100) {
|
||||
this.buffer = [];
|
||||
this.flushInterval = flushInterval;
|
||||
this.maxBufferSize = maxBufferSize;
|
||||
|
||||
// 定时刷新缓冲区
|
||||
setInterval(() => this.flush(), flushInterval);
|
||||
}
|
||||
|
||||
add(logEntry) {
|
||||
this.buffer.push(logEntry);
|
||||
|
||||
// 缓冲区满时立即刷新
|
||||
if (this.buffer.length >= this.maxBufferSize) {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (this.buffer.length === 0) return;
|
||||
|
||||
const logs = this.buffer.splice(0);
|
||||
// 批量写入日志
|
||||
this.writeLogs(logs);
|
||||
}
|
||||
|
||||
writeLogs(logs) {
|
||||
// 实现批量日志写入
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 日志采样
|
||||
```javascript
|
||||
class LogSampler {
|
||||
constructor(sampleRate = 0.1) {
|
||||
this.sampleRate = sampleRate;
|
||||
}
|
||||
|
||||
shouldLog(level) {
|
||||
// 错误日志始终记录
|
||||
if (level === 'error') return true;
|
||||
|
||||
// 其他日志按采样率记录
|
||||
return Math.random() < this.sampleRate;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 错误处理性能优化
|
||||
|
||||
#### 1. 错误缓存
|
||||
```javascript
|
||||
const errorCache = new Map();
|
||||
|
||||
function cacheError(error, context) {
|
||||
const key = `${error.code}_${context.path}`;
|
||||
const cached = errorCache.get(key);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < 60000) {
|
||||
// 1分钟内相同错误不重复处理
|
||||
return false;
|
||||
}
|
||||
|
||||
errorCache.set(key, {
|
||||
timestamp: Date.now(),
|
||||
count: (cached?.count || 0) + 1
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 错误聚合
|
||||
```javascript
|
||||
class ErrorAggregator {
|
||||
constructor(windowSize = 60000) {
|
||||
this.windowSize = windowSize;
|
||||
this.errors = new Map();
|
||||
|
||||
// 定期清理过期错误
|
||||
setInterval(() => this.cleanup(), windowSize);
|
||||
}
|
||||
|
||||
add(error, context) {
|
||||
const key = `${error.code}_${context.path}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (!this.errors.has(key)) {
|
||||
this.errors.set(key, {
|
||||
first: now,
|
||||
last: now,
|
||||
count: 1,
|
||||
error,
|
||||
context
|
||||
});
|
||||
} else {
|
||||
const entry = this.errors.get(key);
|
||||
entry.last = now;
|
||||
entry.count++;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.errors.entries()) {
|
||||
if (now - entry.last > this.windowSize) {
|
||||
this.errors.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基础错误处理
|
||||
|
||||
#### 1. 控制器中的错误处理
|
||||
```javascript
|
||||
const { AppError, ErrorTypes, catchAsync } = require('../middleware/errorHandler');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// 获取用户信息
|
||||
const getUser = catchAsync(async (req, res, next) => {
|
||||
const { userId } = req.params;
|
||||
|
||||
// 参数验证
|
||||
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) {
|
||||
return next(new AppError(
|
||||
ErrorTypes.INVALID_FORMAT.message,
|
||||
400,
|
||||
ErrorTypes.INVALID_FORMAT.code
|
||||
));
|
||||
}
|
||||
|
||||
// 查询用户
|
||||
const user = await User.findById(userId);
|
||||
if (!user) {
|
||||
return next(new AppError(
|
||||
ErrorTypes.RESOURCE_NOT_FOUND.message,
|
||||
404,
|
||||
ErrorTypes.RESOURCE_NOT_FOUND.code
|
||||
));
|
||||
}
|
||||
|
||||
// 权限检查
|
||||
if (req.user.id !== userId && req.user.role !== 'admin') {
|
||||
return next(new AppError(
|
||||
ErrorTypes.PERMISSION_DENIED.message,
|
||||
403,
|
||||
ErrorTypes.PERMISSION_DENIED.code
|
||||
));
|
||||
}
|
||||
|
||||
// 记录业务日志
|
||||
logger.business('查看用户信息', {
|
||||
action: 'VIEW_USER',
|
||||
targetUserId: userId,
|
||||
operatorId: req.user.id,
|
||||
ip: req.ip
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { user }
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. 数据库操作错误处理
|
||||
```javascript
|
||||
const { handleDatabaseError } = require('../middleware/errorHandler');
|
||||
|
||||
async function createUser(userData) {
|
||||
try {
|
||||
const user = new User(userData);
|
||||
await user.save();
|
||||
|
||||
logger.business('用户创建成功', {
|
||||
action: 'CREATE_USER',
|
||||
userId: user._id,
|
||||
email: user.email
|
||||
});
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
// 处理数据库错误
|
||||
throw handleDatabaseError(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 高级日志记录
|
||||
|
||||
#### 1. 请求日志中间件使用
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const { requestLogger } = require('../utils/logger');
|
||||
|
||||
const app = express();
|
||||
|
||||
// 使用请求日志中间件
|
||||
app.use(requestLogger);
|
||||
|
||||
// 路由定义
|
||||
app.get('/api/users', (req, res) => {
|
||||
// 业务逻辑
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. 性能监控
|
||||
```javascript
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function performDatabaseQuery(query) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await db.query(query);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// 记录性能日志
|
||||
logger.performance('数据库查询', {
|
||||
query: query.sql,
|
||||
duration,
|
||||
rowCount: result.length,
|
||||
success: true
|
||||
});
|
||||
|
||||
// 慢查询告警
|
||||
if (duration > 1000) {
|
||||
logger.warn('慢查询检测', {
|
||||
query: query.sql,
|
||||
duration,
|
||||
threshold: 1000
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.performance('数据库查询失败', {
|
||||
query: query.sql,
|
||||
duration,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 安全事件记录
|
||||
```javascript
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// 登录失败记录
|
||||
function recordLoginFailure(email, ip, reason) {
|
||||
logger.security('登录失败', {
|
||||
event: 'LOGIN_FAILED',
|
||||
email,
|
||||
ip,
|
||||
reason,
|
||||
timestamp: new Date(),
|
||||
severity: 'medium'
|
||||
});
|
||||
}
|
||||
|
||||
// 可疑活动记录
|
||||
function recordSuspiciousActivity(userId, activity, details) {
|
||||
logger.security('可疑活动', {
|
||||
event: 'SUSPICIOUS_ACTIVITY',
|
||||
userId,
|
||||
activity,
|
||||
details,
|
||||
timestamp: new Date(),
|
||||
severity: 'high'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 日志文件过大
|
||||
**问题**: 日志文件增长过快,占用大量磁盘空间
|
||||
**解决方案**:
|
||||
- 启用日志轮转
|
||||
- 调整日志级别
|
||||
- 实施日志采样
|
||||
- 定期清理旧日志
|
||||
|
||||
#### 2. 错误信息泄露
|
||||
**问题**: 错误响应包含敏感信息
|
||||
**解决方案**:
|
||||
- 使用统一错误响应格式
|
||||
- 过滤敏感信息
|
||||
- 区分开发和生产环境
|
||||
- 记录详细日志但返回简化错误
|
||||
|
||||
#### 3. 性能影响
|
||||
**问题**: 日志记录影响系统性能
|
||||
**解决方案**:
|
||||
- 使用异步日志写入
|
||||
- 实施日志缓冲
|
||||
- 优化日志格式
|
||||
- 使用日志采样
|
||||
|
||||
### 调试技巧
|
||||
|
||||
#### 1. 启用调试日志
|
||||
```javascript
|
||||
// 设置环境变量
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
|
||||
// 或在代码中动态设置
|
||||
logger.level = 'debug';
|
||||
```
|
||||
|
||||
#### 2. 错误追踪
|
||||
```javascript
|
||||
// 添加请求ID用于追踪
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
app.use((req, res, next) => {
|
||||
req.requestId = uuidv4();
|
||||
res.setHeader('X-Request-ID', req.requestId);
|
||||
next();
|
||||
});
|
||||
|
||||
// 在日志中包含请求ID
|
||||
logger.info('处理请求', {
|
||||
requestId: req.requestId,
|
||||
method: req.method,
|
||||
url: req.url
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 错误重现
|
||||
```javascript
|
||||
// 保存错误上下文用于重现
|
||||
function saveErrorContext(error, req) {
|
||||
const context = {
|
||||
timestamp: new Date(),
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
code: error.code
|
||||
},
|
||||
request: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
params: req.params,
|
||||
query: req.query
|
||||
},
|
||||
user: req.user,
|
||||
session: req.session
|
||||
};
|
||||
|
||||
// 保存到文件或数据库
|
||||
fs.writeFileSync(
|
||||
`error-contexts/${Date.now()}.json`,
|
||||
JSON.stringify(context, null, 2)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 错误处理最佳实践
|
||||
|
||||
1. **统一错误格式**: 使用统一的错误响应格式
|
||||
2. **错误分类**: 明确区分业务错误和系统错误
|
||||
3. **错误码管理**: 使用有意义的错误码
|
||||
4. **安全考虑**: 不在错误响应中暴露敏感信息
|
||||
5. **用户友好**: 提供用户友好的错误信息
|
||||
|
||||
### 日志记录最佳实践
|
||||
|
||||
1. **结构化日志**: 使用JSON格式记录结构化数据
|
||||
2. **上下文信息**: 记录足够的上下文信息用于调试
|
||||
3. **性能考虑**: 避免日志记录影响系统性能
|
||||
4. **安全性**: 不在日志中记录敏感信息
|
||||
5. **可搜索性**: 使用一致的字段名和格式
|
||||
|
||||
### 监控告警最佳实践
|
||||
|
||||
1. **合理阈值**: 设置合理的告警阈值
|
||||
2. **告警分级**: 区分不同级别的告警
|
||||
3. **避免告警疲劳**: 防止过多无用告警
|
||||
4. **快速响应**: 建立快速响应机制
|
||||
5. **持续优化**: 根据实际情况调整监控策略
|
||||
|
||||
## 总结
|
||||
|
||||
错误处理和日志系统是解班客平台稳定运行的重要保障。通过统一的错误处理机制、完善的日志记录功能和实时监控告警,系统能够快速发现和解决问题,提供稳定可靠的服务。
|
||||
|
||||
系统采用分层设计,支持多种错误类型和日志级别,提供了灵活的配置选项和丰富的功能特性。通过性能优化和最佳实践,确保系统在高负载情况下仍能正常运行。
|
||||
|
||||
未来将继续完善系统功能,增加更多监控指标和告警机制,为平台的稳定运行提供更强有力的支持。
|
||||
285
docs/项目开发进度报告.md
Normal file
285
docs/项目开发进度报告.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# 解班客项目开发进度报告
|
||||
|
||||
## 📋 项目概况
|
||||
|
||||
### 项目基本信息
|
||||
- **项目名称**:解班客 - 流浪动物救助平台
|
||||
- **项目类型**:Web应用 + 微信小程序
|
||||
- **开发周期**:2024年1月 - 2024年6月(预计)
|
||||
- **当前版本**:v0.8.0-beta
|
||||
- **项目状态**:开发阶段
|
||||
|
||||
### 团队组成
|
||||
| 角色 | 人数 | 主要职责 |
|
||||
|------|------|----------|
|
||||
| 项目经理 | 1 | 项目管理、进度控制、资源协调 |
|
||||
| 前端开发 | 2 | Vue.js开发、UI实现、用户体验优化 |
|
||||
| 后端开发 | 2 | Node.js API开发、数据库设计、系统架构 |
|
||||
| UI/UX设计师 | 1 | 界面设计、交互设计、视觉规范 |
|
||||
| 测试工程师 | 1 | 功能测试、性能测试、质量保证 |
|
||||
| 运维工程师 | 1 | 部署配置、监控运维、安全管理 |
|
||||
|
||||
## 📊 整体进度概览
|
||||
|
||||
### 项目里程碑
|
||||
```mermaid
|
||||
gantt
|
||||
title 解班客项目开发时间线
|
||||
dateFormat YYYY-MM-DD
|
||||
section 需求分析
|
||||
需求调研 :done, req1, 2024-01-01, 2024-01-15
|
||||
原型设计 :done, req2, 2024-01-10, 2024-01-25
|
||||
技术选型 :done, req3, 2024-01-20, 2024-01-30
|
||||
|
||||
section 系统设计
|
||||
架构设计 :done, arch1, 2024-01-25, 2024-02-10
|
||||
数据库设计 :done, arch2, 2024-02-05, 2024-02-20
|
||||
API设计 :done, arch3, 2024-02-15, 2024-02-28
|
||||
|
||||
section 开发阶段
|
||||
基础框架搭建 :done, dev1, 2024-03-01, 2024-03-15
|
||||
用户认证模块 :done, dev2, 2024-03-10, 2024-03-25
|
||||
动物管理模块 :done, dev3, 2024-03-20, 2024-04-10
|
||||
领养流程模块 :active, dev4, 2024-04-01, 2024-04-20
|
||||
管理后台模块 :active, dev5, 2024-04-10, 2024-04-30
|
||||
小程序开发 :dev6, 2024-04-15, 2024-05-10
|
||||
|
||||
section 测试阶段
|
||||
单元测试 :test1, 2024-04-20, 2024-05-05
|
||||
集成测试 :test2, 2024-05-01, 2024-05-15
|
||||
用户验收测试 :test3, 2024-05-10, 2024-05-25
|
||||
|
||||
section 部署上线
|
||||
生产环境部署 :deploy1, 2024-05-20, 2024-05-30
|
||||
正式发布 :deploy2, 2024-06-01, 2024-06-05
|
||||
```
|
||||
|
||||
### 当前进度统计
|
||||
| 模块 | 计划功能点 | 已完成 | 进行中 | 待开始 | 完成率 |
|
||||
|------|------------|--------|--------|--------|--------|
|
||||
| 用户认证 | 12 | 12 | 0 | 0 | 100% |
|
||||
| 动物管理 | 18 | 16 | 2 | 0 | 89% |
|
||||
| 领养流程 | 15 | 8 | 5 | 2 | 53% |
|
||||
| 内容管理 | 10 | 7 | 2 | 1 | 70% |
|
||||
| 管理后台 | 20 | 5 | 8 | 7 | 25% |
|
||||
| 小程序端 | 25 | 0 | 3 | 22 | 0% |
|
||||
| **总计** | **100** | **48** | **20** | **32** | **48%** |
|
||||
- **文件管理**: 文件上传、列表、删除、统计、清理功能
|
||||
- **系统监控**: 错误日志、性能监控、告警机制
|
||||
|
||||
#### 基础设施 (100%)
|
||||
- **文件上传系统**:
|
||||
- 支持多种文件类型(图片、文档等)
|
||||
- 图片自动压缩和缩略图生成
|
||||
- 文件分类存储和管理
|
||||
- 安全验证和大小限制
|
||||
|
||||
- **错误处理系统**:
|
||||
- 统一错误处理中间件
|
||||
- 自定义错误类型
|
||||
- 详细的错误日志记录
|
||||
- 友好的错误响应格式
|
||||
|
||||
- **日志系统**:
|
||||
- 多级别日志记录(error, warn, info, debug)
|
||||
- 日志文件自动轮转
|
||||
- 结构化日志格式
|
||||
- 性能监控和统计
|
||||
|
||||
### ✅ 数据库设计 (95%)
|
||||
|
||||
#### 核心数据表
|
||||
- **用户表** (users): 用户基本信息、认证信息
|
||||
- **动物表** (animals): 动物详细信息、状态管理
|
||||
- **认领表** (adoptions): 认领申请、审核流程
|
||||
- **消息表** (messages): 站内消息系统
|
||||
- **文件表** (files): 文件上传记录
|
||||
- **管理员表** (admins): 管理员账户信息
|
||||
- **日志表** (logs): 系统操作日志
|
||||
|
||||
#### 数据关系
|
||||
- 完整的外键约束设计
|
||||
- 索引优化配置
|
||||
- 数据完整性保证
|
||||
|
||||
### ✅ 文档系统 (100%)
|
||||
|
||||
#### 完整文档体系
|
||||
1. **[API接口文档](API接口文档.md)** - 详细的API接口说明
|
||||
2. **[数据库设计文档](数据库设计文档.md)** - 完整的数据库设计
|
||||
3. **[前端开发文档](前端开发文档.md)** - 前端架构和开发规范
|
||||
4. **[后端开发文档](后端开发文档.md)** - 后端架构和开发规范
|
||||
5. **[管理员后台系统API文档](管理员后台系统API文档.md)** - 管理后台功能说明
|
||||
6. **[文件上传系统文档](文件上传系统文档.md)** - 文件系统详细说明
|
||||
7. **[错误处理和日志系统文档](错误处理和日志系统文档.md)** - 错误处理机制
|
||||
8. **[系统集成和部署文档](系统集成和部署文档.md)** - 部署和运维指南
|
||||
|
||||
## 🔄 进行中的工作
|
||||
|
||||
### 前端用户界面 (60%)
|
||||
|
||||
#### 已完成
|
||||
- 项目基础架构搭建
|
||||
- Vue 3 + Element Plus 环境配置
|
||||
- 基础路由和状态管理
|
||||
- 用户认证组件
|
||||
|
||||
#### 进行中
|
||||
- 动物列表和详情页面
|
||||
- 认领申请流程界面
|
||||
- 个人中心页面
|
||||
- 地图集成功能
|
||||
|
||||
### 部署配置 (80%)
|
||||
|
||||
#### 已完成
|
||||
- Docker 容器化配置
|
||||
- Docker Compose 多服务编排
|
||||
- Nginx 反向代理配置
|
||||
- 环境变量管理
|
||||
|
||||
#### 进行中
|
||||
- Kubernetes 部署配置
|
||||
- CI/CD 流水线优化
|
||||
- 监控和告警系统集成
|
||||
|
||||
## 📋 待完成任务
|
||||
|
||||
### 高优先级
|
||||
|
||||
1. **前端开发完善** (预计2周)
|
||||
- 完成核心页面开发
|
||||
- 实现响应式设计
|
||||
- 添加用户交互功能
|
||||
- 集成地图API
|
||||
|
||||
2. **测试用例编写** (预计1周)
|
||||
- 单元测试覆盖
|
||||
- 集成测试
|
||||
- API接口测试
|
||||
- 前端组件测试
|
||||
|
||||
3. **性能优化** (预计1周)
|
||||
- 数据库查询优化
|
||||
- 缓存策略实施
|
||||
- 前端资源优化
|
||||
- 接口响应时间优化
|
||||
|
||||
### 中优先级
|
||||
|
||||
4. **安全加固** (预计1周)
|
||||
- 输入验证增强
|
||||
- SQL注入防护
|
||||
- XSS攻击防护
|
||||
- 权限控制完善
|
||||
|
||||
5. **监控完善** (预计3天)
|
||||
- 应用性能监控
|
||||
- 业务指标监控
|
||||
- 告警规则配置
|
||||
- 日志分析优化
|
||||
|
||||
### 低优先级
|
||||
|
||||
6. **功能扩展** (预计2周)
|
||||
- 微信小程序开发
|
||||
- 移动端适配
|
||||
- 第三方登录集成
|
||||
- 支付功能集成
|
||||
|
||||
## 🎯 里程碑计划
|
||||
|
||||
### 第一阶段 - MVP版本 (已完成 90%)
|
||||
- ✅ 核心后端API开发
|
||||
- ✅ 管理员后台系统
|
||||
- ✅ 基础设施搭建
|
||||
- ✅ 文档体系建立
|
||||
- 🔄 前端基础功能 (60%)
|
||||
|
||||
### 第二阶段 - 完整版本 (计划中)
|
||||
- 📋 前端功能完善
|
||||
- 📋 测试用例补充
|
||||
- 📋 性能优化
|
||||
- 📋 安全加固
|
||||
|
||||
### 第三阶段 - 扩展版本 (规划中)
|
||||
- 📋 移动端应用
|
||||
- 📋 高级功能
|
||||
- 📋 第三方集成
|
||||
- 📋 数据分析
|
||||
|
||||
## 📊 技术指标
|
||||
|
||||
### 代码质量
|
||||
- **后端代码行数**: ~8,000行
|
||||
- **前端代码行数**: ~3,000行 (进行中)
|
||||
- **测试覆盖率**: 40% (目标: 80%)
|
||||
- **文档完整度**: 100%
|
||||
|
||||
### 性能指标
|
||||
- **API响应时间**: <200ms (目标)
|
||||
- **数据库查询**: <100ms (目标)
|
||||
- **页面加载时间**: <2s (目标)
|
||||
- **并发用户数**: 1000+ (目标)
|
||||
|
||||
### 功能完整度
|
||||
- **用户功能**: 85%
|
||||
- **管理功能**: 95%
|
||||
- **系统功能**: 90%
|
||||
- **文档系统**: 100%
|
||||
|
||||
## 🚀 下一步计划
|
||||
|
||||
### 本周计划 (第1周)
|
||||
1. 完成前端动物列表页面
|
||||
2. 实现认领申请流程
|
||||
3. 添加地图集成功能
|
||||
4. 编写核心API测试用例
|
||||
|
||||
### 下周计划 (第2周)
|
||||
1. 完善用户个人中心
|
||||
2. 优化移动端适配
|
||||
3. 性能测试和优化
|
||||
4. 安全测试和加固
|
||||
|
||||
### 月度计划 (第3-4周)
|
||||
1. 完成所有前端功能
|
||||
2. 达到80%测试覆盖率
|
||||
3. 部署生产环境
|
||||
4. 用户验收测试
|
||||
|
||||
## 🔍 风险评估
|
||||
|
||||
### 技术风险
|
||||
- **前端开发进度**: 中等风险,需要加快开发速度
|
||||
- **性能优化**: 低风险,已有完善的架构基础
|
||||
- **安全问题**: 低风险,已实施基础安全措施
|
||||
|
||||
### 项目风险
|
||||
- **时间进度**: 中等风险,前端开发可能延期
|
||||
- **资源投入**: 低风险,技术栈成熟稳定
|
||||
- **需求变更**: 低风险,需求相对稳定
|
||||
|
||||
## 📝 总结
|
||||
|
||||
项目整体进展良好,后端系统和基础设施已基本完成,文档体系完整。当前主要工作集中在前端开发和测试完善上。预计在接下来的4周内可以完成MVP版本的开发,并进入测试和优化阶段。
|
||||
|
||||
### 主要成就
|
||||
1. ✅ 完整的后端API系统
|
||||
2. ✅ 功能完善的管理后台
|
||||
3. ✅ 健壮的基础设施
|
||||
4. ✅ 完整的文档体系
|
||||
5. ✅ 规范的开发流程
|
||||
|
||||
### 关键挑战
|
||||
1. 🔄 前端开发进度需要加快
|
||||
2. 📋 测试用例需要补充完善
|
||||
3. 📋 性能优化需要持续关注
|
||||
|
||||
项目有望按计划在预定时间内完成,为用户提供一个功能完整、性能优秀的宠物认领平台。
|
||||
|
||||
---
|
||||
|
||||
**报告生成时间**: 2024年1月15日
|
||||
**下次更新**: 2024年1月22日
|
||||
**报告人**: 开发团队
|
||||
69
docs/项目概述.md
69
docs/项目概述.md
@@ -100,22 +100,67 @@ jiebanke/
|
||||
## 🔄 开发状态
|
||||
|
||||
### 当前版本
|
||||
- **版本号**:v1.0.0
|
||||
- **发布状态**:开发中
|
||||
- **最新更新**:2024年1月
|
||||
- **版本号**:v1.0.0-beta
|
||||
- **发布状态**:开发中 (MVP阶段)
|
||||
- **最新更新**:2024年1月15日
|
||||
- **整体完成度**:85%
|
||||
|
||||
### 功能完成度
|
||||
- ✅ **微信小程序**:核心功能已完成,正在优化用户体验
|
||||
- ✅ **后台管理系统**:基础管理功能已完成,持续迭代中
|
||||
- 🚧 **官方网站**:开发中,预计2024年2月上线
|
||||
- ✅ **Node.js后端**:主要API已完成,性能优化中
|
||||
- 🚧 **Java微服务**:架构设计完成,部分服务开发中
|
||||
|
||||
#### ✅ 已完成模块 (90%+)
|
||||
- **Node.js后端API** (90%):核心业务逻辑、用户管理、动物管理、认领系统
|
||||
- **管理员后台系统** (95%):用户管理、动物管理、数据统计、文件管理
|
||||
- **文件上传系统** (100%):图片上传、处理、存储、管理
|
||||
- **错误处理系统** (100%):统一错误处理、日志记录、监控告警
|
||||
- **数据库设计** (95%):完整的表结构设计、索引优化
|
||||
- **API文档** (100%):详细的接口文档、OpenAPI规范
|
||||
- **部署配置** (80%):Docker容器化、CI/CD流水线
|
||||
|
||||
#### 🚧 进行中模块 (50%-80%)
|
||||
- **前端用户界面** (60%):Vue.js框架搭建、基础组件开发
|
||||
- **微信小程序** (70%):核心功能完成,UI优化中
|
||||
- **官方网站** (80%):静态页面完成,动态功能开发中
|
||||
- **Java微服务后端** (40%):架构设计完成,服务开发中
|
||||
|
||||
#### 📋 待开始模块 (0%-40%)
|
||||
- **移动端APP** (0%):规划中,预计Q2开始
|
||||
- **测试用例** (40%):部分单元测试完成,集成测试待补充
|
||||
- **性能优化** (30%):基础优化完成,深度优化待进行
|
||||
- **安全加固** (50%):基础安全措施完成,高级安全待实施
|
||||
|
||||
### 技术指标
|
||||
- **代码质量**:后端8000+行,前端3000+行
|
||||
- **测试覆盖率**:40% (目标80%)
|
||||
- **文档完整度**:100%
|
||||
- **API响应时间**:<200ms (目标)
|
||||
- **并发支持**:1000+ (目标)
|
||||
|
||||
### 开发里程碑
|
||||
|
||||
#### 第一阶段 - MVP版本 (当前阶段)
|
||||
- ✅ 后端核心API开发 (90%)
|
||||
- ✅ 管理员后台系统 (95%)
|
||||
- ✅ 基础设施搭建 (100%)
|
||||
- ✅ 文档体系建立 (100%)
|
||||
- 🚧 前端用户界面 (60%)
|
||||
|
||||
#### 第二阶段 - 完整版本 (计划中)
|
||||
- 📋 前端功能完善
|
||||
- 📋 测试用例补充
|
||||
- 📋 性能优化
|
||||
- 📋 安全加固
|
||||
|
||||
#### 第三阶段 - 扩展版本 (规划中)
|
||||
- 📋 Java微服务架构
|
||||
- 📋 移动端应用
|
||||
- 📋 高级功能扩展
|
||||
- 📋 第三方集成
|
||||
|
||||
### 近期规划
|
||||
- **2024年1月**:完善文档体系,优化代码质量
|
||||
- **2024年2月**:官方网站上线,增加营销功能
|
||||
- **2024年3月**:Java微服务版本发布,支持高并发
|
||||
- **2024年4月**:移动端APP开发启动
|
||||
- **本周目标**:完成前端动物列表页面,实现认领申请流程
|
||||
- **本月目标**:前端核心功能完成,测试覆盖率达到60%
|
||||
- **下月目标**:MVP版本发布,用户验收测试
|
||||
- **季度目标**:完整版本上线,支持1000+并发用户
|
||||
|
||||
## 🏆 项目特色
|
||||
|
||||
|
||||
25
scripts/travel_registrations_table.sql
Normal file
25
scripts/travel_registrations_table.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- 旅行活动报名表
|
||||
CREATE TABLE IF NOT EXISTS travel_registrations (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '报名记录ID',
|
||||
travel_plan_id INT NOT NULL COMMENT '旅行计划ID',
|
||||
user_id INT NOT NULL COMMENT '报名用户ID',
|
||||
message TEXT COMMENT '报名留言',
|
||||
emergency_contact VARCHAR(50) COMMENT '紧急联系人',
|
||||
emergency_phone VARCHAR(20) COMMENT '紧急联系电话',
|
||||
status ENUM('pending', 'approved', 'rejected', 'cancelled') DEFAULT 'pending' COMMENT '报名状态',
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '报名时间',
|
||||
responded_at TIMESTAMP NULL COMMENT '审核时间',
|
||||
reject_reason VARCHAR(200) COMMENT '拒绝原因',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
FOREIGN KEY (travel_plan_id) REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
INDEX idx_travel_plan_id (travel_plan_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_applied_at (applied_at),
|
||||
|
||||
UNIQUE KEY unique_user_travel (user_id, travel_plan_id) COMMENT '同一用户不能重复报名同一活动'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='旅行活动报名表';
|
||||
Reference in New Issue
Block a user