重构认证系统和订单支付功能,新增邮箱验证、密码重置及支付流程

This commit is contained in:
2025-09-20 16:15:59 +08:00
parent 68a96b7e82
commit 467a4ead10
60 changed files with 32222 additions and 63 deletions

View 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, '手机13800138002QQ123456789', '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();

View 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`);

View File

@@ -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处理

View 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;

View 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);
}
};

View 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
};

View 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);
}
};

View 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();

View File

@@ -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
};

View File

@@ -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 || '支付订单失败'

View 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();

View 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;

View 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
};

View 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
};

View 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;

View 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;

View 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;

View File

@@ -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次失败后锁定
}
}

View File

@@ -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;

View 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;

View 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;

View 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: 文件IDBase64编码的文件路径
* 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;

View 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;

View 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;

View File

@@ -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);
/**

View 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;

View 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;

View 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();

View File

@@ -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

View 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&timestamp=${timestamp}&version=1.0&notify_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();

View 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
View 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
View 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
};