重构认证系统和订单支付功能,新增邮箱验证、密码重置及支付流程
This commit is contained in:
236
backend/scripts/animal_claims_table.sql
Normal file
236
backend/scripts/animal_claims_table.sql
Normal file
@@ -0,0 +1,236 @@
|
||||
-- 动物认领申请表
|
||||
CREATE TABLE IF NOT EXISTS animal_claims (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '认领申请ID',
|
||||
claim_no VARCHAR(32) NOT NULL UNIQUE COMMENT '认领订单号',
|
||||
animal_id INT NOT NULL COMMENT '动物ID',
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
claim_reason TEXT COMMENT '认领理由',
|
||||
claim_duration INT NOT NULL DEFAULT 12 COMMENT '认领时长(月)',
|
||||
total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '总金额',
|
||||
contact_info VARCHAR(500) NOT NULL COMMENT '联系方式',
|
||||
status ENUM('pending', 'approved', 'rejected', 'cancelled') NOT NULL DEFAULT 'pending' COMMENT '申请状态',
|
||||
start_date DATETIME NULL COMMENT '开始日期',
|
||||
end_date DATETIME NULL COMMENT '结束日期',
|
||||
reviewed_by INT NULL COMMENT '审核人ID',
|
||||
review_remark TEXT COMMENT '审核备注',
|
||||
reviewed_at DATETIME NULL COMMENT '审核时间',
|
||||
approved_at DATETIME NULL COMMENT '通过时间',
|
||||
cancelled_at DATETIME NULL COMMENT '取消时间',
|
||||
cancel_reason VARCHAR(500) NULL COMMENT '取消原因',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted_at DATETIME NULL COMMENT '删除时间',
|
||||
|
||||
-- 外键约束
|
||||
FOREIGN KEY (animal_id) REFERENCES animals(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- 索引
|
||||
INDEX idx_animal_id (animal_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_claim_no (claim_no),
|
||||
INDEX idx_deleted_at (deleted_at),
|
||||
|
||||
-- 唯一约束:同一用户对同一动物在同一时间只能有一个有效申请
|
||||
UNIQUE KEY uk_user_animal_active (user_id, animal_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='动物认领申请表';
|
||||
|
||||
-- 动物认领续期记录表
|
||||
CREATE TABLE IF NOT EXISTS animal_claim_renewals (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '续期记录ID',
|
||||
claim_id INT NOT NULL COMMENT '认领申请ID',
|
||||
duration INT NOT NULL COMMENT '续期时长(月)',
|
||||
amount DECIMAL(10,2) NOT NULL COMMENT '续期金额',
|
||||
payment_method ENUM('wechat', 'alipay', 'bank_transfer') NOT NULL COMMENT '支付方式',
|
||||
status ENUM('pending', 'paid', 'cancelled') NOT NULL DEFAULT 'pending' COMMENT '续期状态',
|
||||
payment_no VARCHAR(64) NULL COMMENT '支付订单号',
|
||||
paid_at DATETIME NULL COMMENT '支付时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
-- 外键约束
|
||||
FOREIGN KEY (claim_id) REFERENCES animal_claims(id) ON DELETE CASCADE,
|
||||
|
||||
-- 索引
|
||||
INDEX idx_claim_id (claim_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_payment_no (payment_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='动物认领续期记录表';
|
||||
|
||||
-- 插入测试数据
|
||||
INSERT INTO animal_claims (
|
||||
claim_no, animal_id, user_id, claim_reason, claim_duration,
|
||||
total_amount, contact_info, status, created_at
|
||||
) VALUES
|
||||
(
|
||||
'CLAIM20241201001', 1, 2, '我很喜欢这只小狗,希望能够认领它', 12,
|
||||
1200.00, '手机:13800138001,微信:user001', 'pending', '2024-12-01 10:00:00'
|
||||
),
|
||||
(
|
||||
'CLAIM20241201002', 2, 3, '想要认领这只小猫,会好好照顾它', 6,
|
||||
600.00, '手机:13800138002,QQ:123456789', 'approved', '2024-12-01 11:00:00'
|
||||
),
|
||||
(
|
||||
'CLAIM20241201003', 3, 4, '希望认领这只兔子,家里有足够的空间', 24,
|
||||
2400.00, '手机:13800138003,邮箱:user003@example.com', 'rejected', '2024-12-01 12:00:00'
|
||||
);
|
||||
|
||||
-- 更新已通过的认领申请的时间信息
|
||||
UPDATE animal_claims
|
||||
SET
|
||||
start_date = '2024-12-01 11:30:00',
|
||||
end_date = '2025-06-01 11:30:00',
|
||||
reviewed_by = 1,
|
||||
review_remark = '申请材料完整,同意认领',
|
||||
reviewed_at = '2024-12-01 11:30:00',
|
||||
approved_at = '2024-12-01 11:30:00'
|
||||
WHERE claim_no = 'CLAIM20241201002';
|
||||
|
||||
-- 更新被拒绝的认领申请的审核信息
|
||||
UPDATE animal_claims
|
||||
SET
|
||||
reviewed_by = 1,
|
||||
review_remark = '认领时长过长,建议缩短认领期限后重新申请',
|
||||
reviewed_at = '2024-12-01 12:30:00'
|
||||
WHERE claim_no = 'CLAIM20241201003';
|
||||
|
||||
-- 插入续期记录测试数据
|
||||
INSERT INTO animal_claim_renewals (
|
||||
claim_id, duration, amount, payment_method, status, created_at
|
||||
) VALUES
|
||||
(
|
||||
2, 6, 600.00, 'wechat', 'pending', '2024-12-01 15:00:00'
|
||||
);
|
||||
|
||||
-- 创建视图:认领申请详情视图
|
||||
CREATE OR REPLACE VIEW v_animal_claim_details AS
|
||||
SELECT
|
||||
ac.id,
|
||||
ac.claim_no,
|
||||
ac.animal_id,
|
||||
a.name as animal_name,
|
||||
a.type as animal_type,
|
||||
a.breed as animal_breed,
|
||||
a.age as animal_age,
|
||||
a.gender as animal_gender,
|
||||
a.image as animal_image,
|
||||
a.price as animal_price,
|
||||
ac.user_id,
|
||||
u.username,
|
||||
u.phone as user_phone,
|
||||
u.email as user_email,
|
||||
ac.claim_reason,
|
||||
ac.claim_duration,
|
||||
ac.total_amount,
|
||||
ac.contact_info,
|
||||
ac.status,
|
||||
ac.start_date,
|
||||
ac.end_date,
|
||||
ac.reviewed_by,
|
||||
reviewer.username as reviewer_name,
|
||||
ac.review_remark,
|
||||
ac.reviewed_at,
|
||||
ac.approved_at,
|
||||
ac.cancelled_at,
|
||||
ac.cancel_reason,
|
||||
ac.created_at,
|
||||
ac.updated_at,
|
||||
-- 计算剩余天数
|
||||
CASE
|
||||
WHEN ac.status = 'approved' AND ac.end_date > NOW()
|
||||
THEN DATEDIFF(ac.end_date, NOW())
|
||||
ELSE 0
|
||||
END as remaining_days,
|
||||
-- 是否即将到期(30天内)
|
||||
CASE
|
||||
WHEN ac.status = 'approved' AND ac.end_date > NOW() AND DATEDIFF(ac.end_date, NOW()) <= 30
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END as is_expiring_soon
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN animals a ON ac.animal_id = a.id
|
||||
LEFT JOIN users u ON ac.user_id = u.id
|
||||
LEFT JOIN users reviewer ON ac.reviewed_by = reviewer.id
|
||||
WHERE ac.deleted_at IS NULL;
|
||||
|
||||
-- 创建触发器:认领申请通过时更新动物状态
|
||||
DELIMITER //
|
||||
CREATE TRIGGER tr_animal_claim_approved
|
||||
AFTER UPDATE ON animal_claims
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
-- 如果认领申请从其他状态变为已通过
|
||||
IF OLD.status != 'approved' AND NEW.status = 'approved' THEN
|
||||
UPDATE animals SET status = 'claimed', claim_count = claim_count + 1 WHERE id = NEW.animal_id;
|
||||
END IF;
|
||||
|
||||
-- 如果认领申请从已通过变为其他状态
|
||||
IF OLD.status = 'approved' AND NEW.status != 'approved' THEN
|
||||
UPDATE animals SET status = 'available' WHERE id = NEW.animal_id;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
-- 创建存储过程:批量处理过期的认领申请
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE sp_handle_expired_claims()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT FALSE;
|
||||
DECLARE claim_id INT;
|
||||
DECLARE animal_id INT;
|
||||
|
||||
-- 声明游标
|
||||
DECLARE expired_cursor CURSOR FOR
|
||||
SELECT id, animal_id
|
||||
FROM animal_claims
|
||||
WHERE status = 'approved'
|
||||
AND end_date < NOW()
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
|
||||
|
||||
-- 开始事务
|
||||
START TRANSACTION;
|
||||
|
||||
-- 打开游标
|
||||
OPEN expired_cursor;
|
||||
|
||||
read_loop: LOOP
|
||||
FETCH expired_cursor INTO claim_id, animal_id;
|
||||
IF done THEN
|
||||
LEAVE read_loop;
|
||||
END IF;
|
||||
|
||||
-- 更新认领申请状态为已过期
|
||||
UPDATE animal_claims
|
||||
SET status = 'expired', updated_at = NOW()
|
||||
WHERE id = claim_id;
|
||||
|
||||
-- 更新动物状态为可认领
|
||||
UPDATE animals
|
||||
SET status = 'available', updated_at = NOW()
|
||||
WHERE id = animal_id;
|
||||
|
||||
END LOOP;
|
||||
|
||||
-- 关闭游标
|
||||
CLOSE expired_cursor;
|
||||
|
||||
-- 提交事务
|
||||
COMMIT;
|
||||
|
||||
-- 返回处理的记录数
|
||||
SELECT ROW_COUNT() as processed_count;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
-- 创建事件:每天自动处理过期的认领申请
|
||||
CREATE EVENT IF NOT EXISTS ev_handle_expired_claims
|
||||
ON SCHEDULE EVERY 1 DAY
|
||||
STARTS '2024-12-01 02:00:00'
|
||||
DO
|
||||
CALL sp_handle_expired_claims();
|
||||
70
backend/scripts/payments_table.sql
Normal file
70
backend/scripts/payments_table.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
-- 支付订单表
|
||||
CREATE TABLE IF NOT EXISTS `payments` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '支付订单ID',
|
||||
`payment_no` varchar(64) NOT NULL COMMENT '支付订单号',
|
||||
`order_id` int(11) NOT NULL COMMENT '关联订单ID',
|
||||
`user_id` int(11) NOT NULL COMMENT '用户ID',
|
||||
`amount` decimal(10,2) NOT NULL COMMENT '支付金额',
|
||||
`paid_amount` decimal(10,2) DEFAULT NULL COMMENT '实际支付金额',
|
||||
`payment_method` enum('wechat','alipay','balance') NOT NULL COMMENT '支付方式:wechat-微信支付,alipay-支付宝,balance-余额支付',
|
||||
`status` enum('pending','paid','failed','refunded','cancelled') NOT NULL DEFAULT 'pending' COMMENT '支付状态:pending-待支付,paid-已支付,failed-支付失败,refunded-已退款,cancelled-已取消',
|
||||
`transaction_id` varchar(128) DEFAULT NULL COMMENT '第三方交易号',
|
||||
`return_url` varchar(255) DEFAULT NULL COMMENT '支付成功回调地址',
|
||||
`notify_url` varchar(255) DEFAULT NULL COMMENT '异步通知地址',
|
||||
`paid_at` datetime DEFAULT NULL COMMENT '支付时间',
|
||||
`failure_reason` varchar(255) DEFAULT NULL COMMENT '失败原因',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted_at` datetime DEFAULT NULL COMMENT '删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_payment_no` (`payment_no`),
|
||||
KEY `idx_order_id` (`order_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_payment_method` (`payment_method`),
|
||||
KEY `idx_transaction_id` (`transaction_id`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
KEY `idx_deleted_at` (`deleted_at`),
|
||||
CONSTRAINT `fk_payments_order_id` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_payments_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付订单表';
|
||||
|
||||
-- 退款记录表
|
||||
CREATE TABLE IF NOT EXISTS `refunds` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '退款ID',
|
||||
`refund_no` varchar(64) NOT NULL COMMENT '退款订单号',
|
||||
`payment_id` int(11) NOT NULL COMMENT '支付订单ID',
|
||||
`user_id` int(11) NOT NULL COMMENT '用户ID',
|
||||
`refund_amount` decimal(10,2) NOT NULL COMMENT '退款金额',
|
||||
`refund_reason` varchar(500) NOT NULL COMMENT '退款原因',
|
||||
`status` enum('pending','approved','rejected','completed') NOT NULL DEFAULT 'pending' COMMENT '退款状态:pending-待处理,approved-已同意,rejected-已拒绝,completed-已完成',
|
||||
`processed_by` int(11) DEFAULT NULL COMMENT '处理人ID',
|
||||
`process_remark` varchar(500) DEFAULT NULL COMMENT '处理备注',
|
||||
`refund_transaction_id` varchar(128) DEFAULT NULL COMMENT '退款交易号',
|
||||
`processed_at` datetime DEFAULT NULL COMMENT '处理时间',
|
||||
`refunded_at` datetime DEFAULT NULL COMMENT '退款完成时间',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted_at` datetime DEFAULT NULL COMMENT '删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_refund_no` (`refund_no`),
|
||||
KEY `idx_payment_id` (`payment_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_processed_by` (`processed_by`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
KEY `idx_deleted_at` (`deleted_at`),
|
||||
CONSTRAINT `fk_refunds_payment_id` FOREIGN KEY (`payment_id`) REFERENCES `payments` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_refunds_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_refunds_processed_by` FOREIGN KEY (`processed_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退款记录表';
|
||||
|
||||
-- 插入示例数据(可选)
|
||||
-- INSERT INTO `payments` (`payment_no`, `order_id`, `user_id`, `amount`, `payment_method`, `status`) VALUES
|
||||
-- ('PAY202401010001', 1, 1, 299.00, 'wechat', 'pending'),
|
||||
-- ('PAY202401010002', 2, 2, 199.00, 'alipay', 'paid');
|
||||
|
||||
-- 创建索引优化查询性能
|
||||
CREATE INDEX `idx_payments_user_status` ON `payments` (`user_id`, `status`);
|
||||
CREATE INDEX `idx_payments_method_status` ON `payments` (`payment_method`, `status`);
|
||||
CREATE INDEX `idx_refunds_user_status` ON `refunds` (`user_id`, `status`);
|
||||
@@ -15,7 +15,7 @@ const { globalErrorHandler, notFound } = require('./utils/errors');
|
||||
// 检查是否为无数据库模式
|
||||
const NO_DB_MODE = process.env.NO_DB_MODE === 'true';
|
||||
|
||||
let authRoutes, userRoutes, travelRoutes, animalRoutes, orderRoutes, adminRoutes;
|
||||
let authRoutes, userRoutes, travelRoutes, animalRoutes, orderRoutes, adminRoutes, travelRegistrationRoutes;
|
||||
|
||||
// 路由导入 - 根据是否为无数据库模式决定是否导入实际路由
|
||||
if (NO_DB_MODE) {
|
||||
@@ -28,6 +28,9 @@ if (NO_DB_MODE) {
|
||||
animalRoutes = require('./routes/animal');
|
||||
orderRoutes = require('./routes/order');
|
||||
adminRoutes = require('./routes/admin'); // 新增管理员路由
|
||||
travelRegistrationRoutes = require('./routes/travelRegistration'); // 旅行报名路由
|
||||
paymentRoutes = require('./routes/payment');
|
||||
animalClaimRoutes = require('./routes/animalClaim'); // 动物认领路由
|
||||
}
|
||||
|
||||
const app = express();
|
||||
@@ -177,6 +180,27 @@ if (NO_DB_MODE) {
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/v1/travel-registration', (req, res) => {
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
message: '当前为无数据库模式,旅行报名功能不可用'
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/v1/payments', (req, res) => {
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
message: '当前为无数据库模式,支付功能不可用'
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/v1/animal-claims', (req, res) => {
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
message: '当前为无数据库模式,动物认领功能不可用'
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/v1/admin', (req, res) => {
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
@@ -190,8 +214,13 @@ if (NO_DB_MODE) {
|
||||
app.use('/api/v1/travel', travelRoutes);
|
||||
app.use('/api/v1/animals', animalRoutes);
|
||||
app.use('/api/v1/orders', orderRoutes);
|
||||
app.use('/api/v1/payments', paymentRoutes);
|
||||
// 动物认领路由
|
||||
app.use('/api/v1/animal-claims', animalClaimRoutes);
|
||||
// 管理员路由
|
||||
app.use('/api/v1/admin', adminRoutes);
|
||||
// 旅行报名路由
|
||||
app.use('/api/v1/travel-registration', travelRegistrationRoutes);
|
||||
}
|
||||
|
||||
// 404处理
|
||||
|
||||
431
backend/src/controllers/admin/animalManagement.js
Normal file
431
backend/src/controllers/admin/animalManagement.js
Normal file
@@ -0,0 +1,431 @@
|
||||
const Animal = require('../../models/Animal');
|
||||
const AnimalClaim = require('../../models/AnimalClaim');
|
||||
const { validationResult } = require('express-validator');
|
||||
|
||||
/**
|
||||
* 管理员动物管理控制器
|
||||
* @class AnimalManagementController
|
||||
*/
|
||||
class AnimalManagementController {
|
||||
/**
|
||||
* 获取动物列表
|
||||
* @param {Object} req - Express请求对象
|
||||
* @param {Object} res - Express响应对象
|
||||
*/
|
||||
static async getAnimalList(req, res) {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '参数验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
keyword,
|
||||
species,
|
||||
status,
|
||||
merchant_id,
|
||||
start_date,
|
||||
end_date,
|
||||
sort_by = 'created_at',
|
||||
sort_order = 'desc'
|
||||
} = req.query;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// 构建查询条件
|
||||
let whereClause = '';
|
||||
const params = [];
|
||||
|
||||
if (keyword) {
|
||||
whereClause += ' AND (a.name LIKE ? OR a.description LIKE ?)';
|
||||
params.push(`%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
if (species) {
|
||||
whereClause += ' AND a.species = ?';
|
||||
params.push(species);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
whereClause += ' AND a.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (merchant_id) {
|
||||
whereClause += ' AND a.merchant_id = ?';
|
||||
params.push(merchant_id);
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
whereClause += ' AND DATE(a.created_at) >= ?';
|
||||
params.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
whereClause += ' AND DATE(a.created_at) <= ?';
|
||||
params.push(end_date);
|
||||
}
|
||||
|
||||
// 获取动物列表
|
||||
const animals = await Animal.getAnimalListWithMerchant({
|
||||
whereClause,
|
||||
params,
|
||||
sortBy: sort_by,
|
||||
sortOrder: sort_order,
|
||||
limit: parseInt(limit),
|
||||
offset
|
||||
});
|
||||
|
||||
// 获取总数
|
||||
const totalCount = await Animal.getAnimalCount({
|
||||
whereClause,
|
||||
params
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
animals,
|
||||
pagination: {
|
||||
current_page: parseInt(page),
|
||||
per_page: parseInt(limit),
|
||||
total: totalCount,
|
||||
total_pages: Math.ceil(totalCount / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取动物列表失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取动物列表失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动物详情
|
||||
* @param {Object} req - Express请求对象
|
||||
* @param {Object} res - Express响应对象
|
||||
*/
|
||||
static async getAnimalDetail(req, res) {
|
||||
try {
|
||||
const { animal_id } = req.params;
|
||||
|
||||
// 获取动物详情
|
||||
const animal = await Animal.getAnimalDetailWithMerchant(animal_id);
|
||||
if (!animal) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '动物不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取认领统计
|
||||
const claimStats = await AnimalClaim.getAnimalClaimStats(animal_id);
|
||||
|
||||
// 获取最近的认领记录
|
||||
const recentClaims = await AnimalClaim.getAnimalClaimList(animal_id, {
|
||||
limit: 5,
|
||||
offset: 0
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
animal,
|
||||
claimStats,
|
||||
recentClaims
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取动物详情失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取动物详情失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新动物状态
|
||||
* @param {Object} req - Express请求对象
|
||||
* @param {Object} res - Express响应对象
|
||||
*/
|
||||
static async updateAnimalStatus(req, res) {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '参数验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { animal_id } = req.params;
|
||||
const { status, reason } = req.body;
|
||||
const adminId = req.user.id;
|
||||
|
||||
// 检查动物是否存在
|
||||
const animal = await Animal.findById(animal_id);
|
||||
if (!animal) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '动物不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 更新动物状态
|
||||
await Animal.updateAnimalStatus(animal_id, status, adminId, reason);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '动物状态更新成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新动物状态失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '更新动物状态失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新动物状态
|
||||
* @param {Object} req - Express请求对象
|
||||
* @param {Object} res - Express响应对象
|
||||
*/
|
||||
static async batchUpdateAnimalStatus(req, res) {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '参数验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { animal_ids, status, reason } = req.body;
|
||||
const adminId = req.user.id;
|
||||
|
||||
// 批量更新动物状态
|
||||
const results = await Animal.batchUpdateAnimalStatus(animal_ids, status, adminId, reason);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '批量更新动物状态成功',
|
||||
data: {
|
||||
updated_count: results.affectedRows
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('批量更新动物状态失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '批量更新动物状态失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动物统计信息
|
||||
* @param {Object} req - Express请求对象
|
||||
* @param {Object} res - Express响应对象
|
||||
*/
|
||||
static async getAnimalStatistics(req, res) {
|
||||
try {
|
||||
// 获取动物总体统计
|
||||
const totalStats = await Animal.getAnimalTotalStats();
|
||||
|
||||
// 获取按物种分类的统计
|
||||
const speciesStats = await Animal.getAnimalStatsBySpecies();
|
||||
|
||||
// 获取按状态分类的统计
|
||||
const statusStats = await Animal.getAnimalStatsByStatus();
|
||||
|
||||
// 获取按商家分类的统计
|
||||
const merchantStats = await Animal.getAnimalStatsByMerchant();
|
||||
|
||||
// 获取认领统计
|
||||
const claimStats = await AnimalClaim.getClaimTotalStats();
|
||||
|
||||
// 获取月度趋势数据
|
||||
const monthlyTrend = await Animal.getAnimalMonthlyTrend();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
totalStats,
|
||||
speciesStats,
|
||||
statusStats,
|
||||
merchantStats,
|
||||
claimStats,
|
||||
monthlyTrend
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取动物统计信息失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取动物统计信息失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出动物数据
|
||||
* @param {Object} req - Express请求对象
|
||||
* @param {Object} res - Express响应对象
|
||||
*/
|
||||
static async exportAnimalData(req, res) {
|
||||
try {
|
||||
const {
|
||||
format = 'csv',
|
||||
keyword,
|
||||
species,
|
||||
status,
|
||||
merchant_id,
|
||||
start_date,
|
||||
end_date
|
||||
} = req.query;
|
||||
|
||||
// 构建查询条件
|
||||
let whereClause = '';
|
||||
const params = [];
|
||||
|
||||
if (keyword) {
|
||||
whereClause += ' AND (a.name LIKE ? OR a.description LIKE ?)';
|
||||
params.push(`%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
if (species) {
|
||||
whereClause += ' AND a.species = ?';
|
||||
params.push(species);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
whereClause += ' AND a.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (merchant_id) {
|
||||
whereClause += ' AND a.merchant_id = ?';
|
||||
params.push(merchant_id);
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
whereClause += ' AND DATE(a.created_at) >= ?';
|
||||
params.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
whereClause += ' AND DATE(a.created_at) <= ?';
|
||||
params.push(end_date);
|
||||
}
|
||||
|
||||
// 获取导出数据
|
||||
const animals = await Animal.getAnimalExportData({
|
||||
whereClause,
|
||||
params
|
||||
});
|
||||
|
||||
if (format === 'csv') {
|
||||
// 生成CSV格式
|
||||
const csvHeader = 'ID,名称,物种,品种,年龄,性别,价格,状态,商家名称,创建时间\n';
|
||||
const csvData = animals.map(animal =>
|
||||
`${animal.id},"${animal.name}","${animal.species}","${animal.breed || ''}",${animal.age || ''},"${animal.gender || ''}",${animal.price},"${animal.status}","${animal.merchant_name}","${animal.created_at}"`
|
||||
).join('\n');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="animals_${Date.now()}.csv"`);
|
||||
res.send('\ufeff' + csvHeader + csvData); // 添加BOM以支持中文
|
||||
} else {
|
||||
// 返回JSON格式
|
||||
res.json({
|
||||
success: true,
|
||||
message: '导出成功',
|
||||
data: {
|
||||
animals,
|
||||
export_time: new Date().toISOString(),
|
||||
total_count: animals.length
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出动物数据失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '导出动物数据失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动物认领记录
|
||||
* @param {Object} req - Express请求对象
|
||||
* @param {Object} res - Express响应对象
|
||||
*/
|
||||
static async getAnimalClaimRecords(req, res) {
|
||||
try {
|
||||
const { animal_id } = req.params;
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status
|
||||
} = req.query;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// 获取认领记录
|
||||
const claims = await AnimalClaim.getAnimalClaimList(animal_id, {
|
||||
status,
|
||||
limit: parseInt(limit),
|
||||
offset
|
||||
});
|
||||
|
||||
// 获取总数
|
||||
const totalCount = await AnimalClaim.getAnimalClaimCount(animal_id, { status });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
claims,
|
||||
pagination: {
|
||||
current_page: parseInt(page),
|
||||
per_page: parseInt(limit),
|
||||
total: totalCount,
|
||||
total_pages: Math.ceil(totalCount / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取动物认领记录失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取动物认领记录失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AnimalManagementController;
|
||||
609
backend/src/controllers/admin/dataStatistics.js
Normal file
609
backend/src/controllers/admin/dataStatistics.js
Normal file
@@ -0,0 +1,609 @@
|
||||
// 管理员数据统计控制器
|
||||
const { query } = require('../../config/database');
|
||||
|
||||
/**
|
||||
* 获取系统概览统计
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.getSystemOverview = async (req, res, next) => {
|
||||
try {
|
||||
// 用户统计
|
||||
const userStatsSql = `
|
||||
SELECT
|
||||
COUNT(*) as total_users,
|
||||
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_users,
|
||||
COUNT(CASE WHEN DATE(created_at) = CURDATE() THEN 1 END) as new_users_today,
|
||||
COUNT(CASE WHEN DATE(created_at) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN 1 END) as new_users_week
|
||||
FROM users
|
||||
`;
|
||||
const userStats = await query(userStatsSql);
|
||||
|
||||
// 旅行统计
|
||||
const travelStatsSql = `
|
||||
SELECT
|
||||
COUNT(*) as total_travels,
|
||||
COUNT(CASE WHEN status = 'published' THEN 1 END) as published_travels,
|
||||
COUNT(CASE WHEN DATE(created_at) = CURDATE() THEN 1 END) as new_travels_today,
|
||||
COUNT(CASE WHEN DATE(created_at) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN 1 END) as new_travels_week
|
||||
FROM travels
|
||||
`;
|
||||
const travelStats = await query(travelStatsSql);
|
||||
|
||||
// 动物统计
|
||||
const animalStatsSql = `
|
||||
SELECT
|
||||
COUNT(*) as total_animals,
|
||||
COUNT(CASE WHEN status = 'available' THEN 1 END) as available_animals,
|
||||
COUNT(CASE WHEN status = 'claimed' THEN 1 END) as claimed_animals
|
||||
FROM animals
|
||||
`;
|
||||
const animalStats = await query(animalStatsSql);
|
||||
|
||||
// 认领统计
|
||||
const claimStatsSql = `
|
||||
SELECT
|
||||
COUNT(*) as total_claims,
|
||||
COUNT(CASE WHEN status = 'approved' THEN 1 END) as approved_claims,
|
||||
COUNT(CASE WHEN DATE(created_at) = CURDATE() THEN 1 END) as new_claims_today,
|
||||
COUNT(CASE WHEN DATE(created_at) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN 1 END) as new_claims_week
|
||||
FROM animal_claims
|
||||
`;
|
||||
const claimStats = await query(claimStatsSql);
|
||||
|
||||
// 订单统计
|
||||
const orderStatsSql = `
|
||||
SELECT
|
||||
COUNT(*) as total_orders,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_orders,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount END), 0) as total_revenue,
|
||||
COUNT(CASE WHEN DATE(created_at) = CURDATE() THEN 1 END) as new_orders_today
|
||||
FROM orders
|
||||
`;
|
||||
const orderStats = await query(orderStatsSql);
|
||||
|
||||
// 推广统计
|
||||
const promotionStatsSql = `
|
||||
SELECT
|
||||
COUNT(DISTINCT user_id) as total_promoters,
|
||||
COALESCE(SUM(commission_amount), 0) as total_commission,
|
||||
COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending_withdrawals
|
||||
FROM promotion_records
|
||||
`;
|
||||
const promotionStats = await query(promotionStatsSql);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
users: userStats[0],
|
||||
travels: travelStats[0],
|
||||
animals: animalStats[0],
|
||||
claims: claimStats[0],
|
||||
orders: orderStats[0],
|
||||
promotions: promotionStats[0]
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户增长趋势
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.getUserGrowthTrend = async (req, res, next) => {
|
||||
try {
|
||||
const { period = '30d' } = req.query;
|
||||
|
||||
let days;
|
||||
switch (period) {
|
||||
case '7d':
|
||||
days = 7;
|
||||
break;
|
||||
case '90d':
|
||||
days = 90;
|
||||
break;
|
||||
case '365d':
|
||||
days = 365;
|
||||
break;
|
||||
default:
|
||||
days = 30;
|
||||
}
|
||||
|
||||
const trendSql = `
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as new_users,
|
||||
COUNT(CASE WHEN user_type = 'farmer' THEN 1 END) as new_farmers,
|
||||
COUNT(CASE WHEN user_type = 'merchant' THEN 1 END) as new_merchants
|
||||
FROM users
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
|
||||
const trendData = await query(trendSql);
|
||||
|
||||
// 计算累计用户数
|
||||
const cumulativeSql = `
|
||||
SELECT COUNT(*) as cumulative_users
|
||||
FROM users
|
||||
WHERE created_at < DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
|
||||
`;
|
||||
const cumulativeResult = await query(cumulativeSql);
|
||||
let cumulativeUsers = cumulativeResult[0].cumulative_users;
|
||||
|
||||
const enrichedTrendData = trendData.map(item => {
|
||||
cumulativeUsers += item.new_users;
|
||||
return {
|
||||
...item,
|
||||
cumulative_users: cumulativeUsers
|
||||
};
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
period,
|
||||
trendData: enrichedTrendData
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取业务数据统计
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.getBusinessStatistics = async (req, res, next) => {
|
||||
try {
|
||||
const { period = '30d' } = req.query;
|
||||
|
||||
let days;
|
||||
switch (period) {
|
||||
case '7d':
|
||||
days = 7;
|
||||
break;
|
||||
case '90d':
|
||||
days = 90;
|
||||
break;
|
||||
default:
|
||||
days = 30;
|
||||
}
|
||||
|
||||
// 旅行数据统计
|
||||
const travelStatsSql = `
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as new_travels,
|
||||
COUNT(CASE WHEN status = 'published' THEN 1 END) as published_travels,
|
||||
COUNT(CASE WHEN status = 'matched' THEN 1 END) as matched_travels
|
||||
FROM travels
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
const travelStats = await query(travelStatsSql);
|
||||
|
||||
// 认领数据统计
|
||||
const claimStatsSql = `
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as new_claims,
|
||||
COUNT(CASE WHEN status = 'approved' THEN 1 END) as approved_claims,
|
||||
COUNT(CASE WHEN status = 'rejected' THEN 1 END) as rejected_claims
|
||||
FROM animal_claims
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
const claimStats = await query(claimStatsSql);
|
||||
|
||||
// 订单数据统计
|
||||
const orderStatsSql = `
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as new_orders,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_orders,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount END), 0) as daily_revenue
|
||||
FROM orders
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
const orderStats = await query(orderStatsSql);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
period,
|
||||
travelStats,
|
||||
claimStats,
|
||||
orderStats
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取地域分布统计
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.getGeographicDistribution = async (req, res, next) => {
|
||||
try {
|
||||
// 用户地域分布
|
||||
const userDistributionSql = `
|
||||
SELECT
|
||||
province,
|
||||
city,
|
||||
COUNT(*) as user_count
|
||||
FROM users
|
||||
WHERE province IS NOT NULL AND city IS NOT NULL
|
||||
GROUP BY province, city
|
||||
ORDER BY user_count DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
const userDistribution = await query(userDistributionSql);
|
||||
|
||||
// 省份统计
|
||||
const provinceStatsSql = `
|
||||
SELECT
|
||||
province,
|
||||
COUNT(*) as user_count,
|
||||
COUNT(CASE WHEN user_type = 'farmer' THEN 1 END) as farmer_count,
|
||||
COUNT(CASE WHEN user_type = 'merchant' THEN 1 END) as merchant_count
|
||||
FROM users
|
||||
WHERE province IS NOT NULL
|
||||
GROUP BY province
|
||||
ORDER BY user_count DESC
|
||||
`;
|
||||
const provinceStats = await query(provinceStatsSql);
|
||||
|
||||
// 旅行目的地统计
|
||||
const destinationStatsSql = `
|
||||
SELECT
|
||||
destination,
|
||||
COUNT(*) as travel_count
|
||||
FROM travels
|
||||
WHERE destination IS NOT NULL
|
||||
GROUP BY destination
|
||||
ORDER BY travel_count DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
const destinationStats = await query(destinationStatsSql);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
userDistribution,
|
||||
provinceStats,
|
||||
destinationStats
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户行为分析
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.getUserBehaviorAnalysis = async (req, res, next) => {
|
||||
try {
|
||||
// 用户活跃度分析
|
||||
const activitySql = `
|
||||
SELECT
|
||||
CASE
|
||||
WHEN last_login_at >= DATE_SUB(NOW(), INTERVAL 1 DAY) THEN '今日活跃'
|
||||
WHEN last_login_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN '本周活跃'
|
||||
WHEN last_login_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN '本月活跃'
|
||||
ELSE '不活跃'
|
||||
END as activity_level,
|
||||
COUNT(*) as user_count
|
||||
FROM users
|
||||
WHERE last_login_at IS NOT NULL
|
||||
GROUP BY activity_level
|
||||
`;
|
||||
const activityStats = await query(activitySql);
|
||||
|
||||
// 用户等级分布
|
||||
const levelDistributionSql = `
|
||||
SELECT
|
||||
level,
|
||||
COUNT(*) as user_count,
|
||||
AVG(points) as avg_points,
|
||||
AVG(travel_count) as avg_travel_count,
|
||||
AVG(animal_claim_count) as avg_claim_count
|
||||
FROM users
|
||||
GROUP BY level
|
||||
ORDER BY
|
||||
CASE level
|
||||
WHEN 'bronze' THEN 1
|
||||
WHEN 'silver' THEN 2
|
||||
WHEN 'gold' THEN 3
|
||||
WHEN 'platinum' THEN 4
|
||||
END
|
||||
`;
|
||||
const levelDistribution = await query(levelDistributionSql);
|
||||
|
||||
// 用户行为偏好
|
||||
const behaviorSql = `
|
||||
SELECT
|
||||
'travel_focused' as behavior_type,
|
||||
COUNT(*) as user_count
|
||||
FROM users
|
||||
WHERE travel_count > animal_claim_count AND travel_count > 0
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'animal_focused' as behavior_type,
|
||||
COUNT(*) as user_count
|
||||
FROM users
|
||||
WHERE animal_claim_count > travel_count AND animal_claim_count > 0
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'balanced' as behavior_type,
|
||||
COUNT(*) as user_count
|
||||
FROM users
|
||||
WHERE travel_count = animal_claim_count AND travel_count > 0
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'inactive' as behavior_type,
|
||||
COUNT(*) as user_count
|
||||
FROM users
|
||||
WHERE travel_count = 0 AND animal_claim_count = 0
|
||||
`;
|
||||
const behaviorStats = await query(behaviorSql);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
activityStats,
|
||||
levelDistribution,
|
||||
behaviorStats
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取收入统计
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.getRevenueStatistics = async (req, res, next) => {
|
||||
try {
|
||||
const { period = '30d' } = req.query;
|
||||
|
||||
let days;
|
||||
switch (period) {
|
||||
case '7d':
|
||||
days = 7;
|
||||
break;
|
||||
case '90d':
|
||||
days = 90;
|
||||
break;
|
||||
case '365d':
|
||||
days = 365;
|
||||
break;
|
||||
default:
|
||||
days = 30;
|
||||
}
|
||||
|
||||
// 收入趋势
|
||||
const revenueTrendSql = `
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount END), 0) as daily_revenue,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_orders,
|
||||
COUNT(*) as total_orders
|
||||
FROM orders
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
const revenueTrend = await query(revenueTrendSql);
|
||||
|
||||
// 收入来源分析
|
||||
const revenueSourceSql = `
|
||||
SELECT
|
||||
order_type,
|
||||
COUNT(*) as order_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount END), 0) as total_revenue,
|
||||
AVG(CASE WHEN status = 'completed' THEN amount END) as avg_order_value
|
||||
FROM orders
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
|
||||
GROUP BY order_type
|
||||
`;
|
||||
const revenueSource = await query(revenueSourceSql);
|
||||
|
||||
// 支付方式统计
|
||||
const paymentMethodSql = `
|
||||
SELECT
|
||||
payment_method,
|
||||
COUNT(*) as order_count,
|
||||
COALESCE(SUM(amount), 0) as total_amount
|
||||
FROM orders
|
||||
WHERE status = 'completed'
|
||||
AND created_at >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)
|
||||
GROUP BY payment_method
|
||||
`;
|
||||
const paymentMethodStats = await query(paymentMethodSql);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
period,
|
||||
revenueTrend,
|
||||
revenueSource,
|
||||
paymentMethodStats
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出统计报告
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.exportStatisticsReport = async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
reportType = 'overview',
|
||||
period = '30d',
|
||||
format = 'csv'
|
||||
} = req.query;
|
||||
|
||||
let reportData = {};
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
|
||||
switch (reportType) {
|
||||
case 'overview':
|
||||
// 获取系统概览数据
|
||||
const overviewSql = `
|
||||
SELECT
|
||||
'用户总数' as metric, COUNT(*) as value FROM users
|
||||
UNION ALL
|
||||
SELECT
|
||||
'活跃用户' as metric, COUNT(*) as value FROM users WHERE status = 'active'
|
||||
UNION ALL
|
||||
SELECT
|
||||
'旅行总数' as metric, COUNT(*) as value FROM travels
|
||||
UNION ALL
|
||||
SELECT
|
||||
'认领总数' as metric, COUNT(*) as value FROM animal_claims
|
||||
UNION ALL
|
||||
SELECT
|
||||
'订单总数' as metric, COUNT(*) as value FROM orders
|
||||
UNION ALL
|
||||
SELECT
|
||||
'总收入' as metric, COALESCE(SUM(amount), 0) as value FROM orders WHERE status = 'completed'
|
||||
`;
|
||||
reportData.overview = await query(overviewSql);
|
||||
break;
|
||||
|
||||
case 'users':
|
||||
// 用户详细报告
|
||||
const userReportSql = `
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as new_users,
|
||||
COUNT(CASE WHEN user_type = 'farmer' THEN 1 END) as new_farmers,
|
||||
COUNT(CASE WHEN user_type = 'merchant' THEN 1 END) as new_merchants
|
||||
FROM users
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
reportData.users = await query(userReportSql);
|
||||
break;
|
||||
|
||||
case 'revenue':
|
||||
// 收入报告
|
||||
const revenueReportSql = `
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as total_orders,
|
||||
COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_orders,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount END), 0) as daily_revenue
|
||||
FROM orders
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
reportData.revenue = await query(revenueReportSql);
|
||||
break;
|
||||
}
|
||||
|
||||
if (format === 'csv') {
|
||||
// 生成CSV格式
|
||||
let csvContent = '';
|
||||
|
||||
Object.keys(reportData).forEach(key => {
|
||||
csvContent += `\n${key.toUpperCase()} 报告\n`;
|
||||
if (reportData[key].length > 0) {
|
||||
// 添加表头
|
||||
const headers = Object.keys(reportData[key][0]).join(',');
|
||||
csvContent += headers + '\n';
|
||||
|
||||
// 添加数据
|
||||
reportData[key].forEach(row => {
|
||||
const values = Object.values(row).join(',');
|
||||
csvContent += values + '\n';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=statistics_report_${timestamp}.csv`);
|
||||
res.send('\uFEFF' + csvContent);
|
||||
} else {
|
||||
// 返回JSON格式
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '导出成功',
|
||||
data: {
|
||||
reportType,
|
||||
period,
|
||||
timestamp,
|
||||
...reportData
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
const logSql = `
|
||||
INSERT INTO admin_operation_logs (admin_id, operation_type, target_type, target_id, operation_detail, created_at)
|
||||
VALUES (?, 'export_statistics', 'system', ?, ?, NOW())
|
||||
`;
|
||||
const operationDetail = JSON.stringify({
|
||||
reportType,
|
||||
period,
|
||||
format
|
||||
});
|
||||
await query(logSql, [req.admin.id, 0, operationDetail]);
|
||||
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
590
backend/src/controllers/admin/fileManagement.js
Normal file
590
backend/src/controllers/admin/fileManagement.js
Normal file
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* 管理员文件管理控制器
|
||||
* 处理文件上传、管理、统计等功能
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { AppError, ErrorTypes, catchAsync } = require('../../middleware/errorHandler');
|
||||
const { logBusinessOperation, logError } = require('../../utils/logger');
|
||||
const { deleteFile, getFileInfo } = require('../../middleware/upload');
|
||||
|
||||
/**
|
||||
* 获取文件列表
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
const getFileList = catchAsync(async (req, res) => {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
type = 'all',
|
||||
keyword = '',
|
||||
start_date,
|
||||
end_date,
|
||||
sort_by = 'created_at',
|
||||
sort_order = 'desc'
|
||||
} = req.query;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const uploadDir = path.join(__dirname, '../../../uploads');
|
||||
|
||||
try {
|
||||
// 获取所有文件类型目录
|
||||
const typeDirs = {
|
||||
avatar: path.join(uploadDir, 'avatars'),
|
||||
animal: path.join(uploadDir, 'animals'),
|
||||
travel: path.join(uploadDir, 'travels'),
|
||||
document: path.join(uploadDir, 'documents')
|
||||
};
|
||||
|
||||
let allFiles = [];
|
||||
|
||||
// 根据类型筛选目录
|
||||
const dirsToScan = type === 'all' ? Object.values(typeDirs) : [typeDirs[type]];
|
||||
|
||||
for (const dir of dirsToScan) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
|
||||
const files = fs.readdirSync(dir);
|
||||
const fileType = Object.keys(typeDirs).find(key => typeDirs[key] === dir);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
// 跳过缩略图文件
|
||||
if (file.includes('_thumb')) continue;
|
||||
|
||||
// 关键词筛选
|
||||
if (keyword && !file.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 日期筛选
|
||||
if (start_date && stats.birthtime < new Date(start_date)) continue;
|
||||
if (end_date && stats.birthtime > new Date(end_date)) continue;
|
||||
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
|
||||
|
||||
allFiles.push({
|
||||
id: Buffer.from(filePath).toString('base64'),
|
||||
filename: file,
|
||||
originalName: file,
|
||||
type: fileType,
|
||||
size: stats.size,
|
||||
mimetype: isImage ? `image/${ext.slice(1)}` : 'application/octet-stream',
|
||||
isImage,
|
||||
url: `/uploads/${fileType}s/${file}`,
|
||||
thumbnailUrl: isImage ? `/uploads/${fileType}s/${file.replace(ext, '_thumb' + ext)}` : null,
|
||||
created_at: stats.birthtime,
|
||||
modified_at: stats.mtime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
allFiles.sort((a, b) => {
|
||||
const aValue = a[sort_by] || a.created_at;
|
||||
const bValue = b[sort_by] || b.created_at;
|
||||
|
||||
if (sort_order === 'desc') {
|
||||
return new Date(bValue) - new Date(aValue);
|
||||
} else {
|
||||
return new Date(aValue) - new Date(bValue);
|
||||
}
|
||||
});
|
||||
|
||||
// 分页
|
||||
const total = allFiles.length;
|
||||
const files = allFiles.slice(offset, offset + parseInt(limit));
|
||||
|
||||
// 记录操作日志
|
||||
logBusinessOperation('file_list_viewed', 'file', {
|
||||
page,
|
||||
limit,
|
||||
type,
|
||||
keyword,
|
||||
total
|
||||
}, req.user);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
files,
|
||||
pagination: {
|
||||
current_page: parseInt(page),
|
||||
per_page: parseInt(limit),
|
||||
total,
|
||||
total_pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, {
|
||||
type: 'file_list_error',
|
||||
userId: req.user?.id,
|
||||
query: req.query
|
||||
});
|
||||
throw ErrorTypes.INTERNAL_ERROR('获取文件列表失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取文件详情
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
const getFileDetail = catchAsync(async (req, res) => {
|
||||
const { file_id } = req.params;
|
||||
|
||||
try {
|
||||
// 解码文件路径
|
||||
const filePath = Buffer.from(file_id, 'base64').toString();
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw ErrorTypes.NOT_FOUND('文件不存在');
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
|
||||
|
||||
// 获取文件类型
|
||||
const uploadDir = path.join(__dirname, '../../../uploads');
|
||||
const relativePath = path.relative(uploadDir, filePath);
|
||||
const fileType = relativePath.split(path.sep)[0].replace('s', ''); // avatars -> avatar
|
||||
|
||||
const fileDetail = {
|
||||
id: file_id,
|
||||
filename,
|
||||
originalName: filename,
|
||||
type: fileType,
|
||||
size: stats.size,
|
||||
mimetype: isImage ? `image/${ext.slice(1)}` : 'application/octet-stream',
|
||||
isImage,
|
||||
url: `/uploads/${fileType}s/${filename}`,
|
||||
thumbnailUrl: isImage ? `/uploads/${fileType}s/${filename.replace(ext, '_thumb' + ext)}` : null,
|
||||
created_at: stats.birthtime,
|
||||
modified_at: stats.mtime,
|
||||
path: relativePath
|
||||
};
|
||||
|
||||
// 记录操作日志
|
||||
logBusinessOperation('file_detail_viewed', 'file', {
|
||||
fileId: file_id,
|
||||
filename
|
||||
}, req.user);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
file: fileDetail
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logError(error, {
|
||||
type: 'file_detail_error',
|
||||
userId: req.user?.id,
|
||||
fileId: file_id
|
||||
});
|
||||
throw ErrorTypes.INTERNAL_ERROR('获取文件详情失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
const deleteFileById = catchAsync(async (req, res) => {
|
||||
const { file_id } = req.params;
|
||||
|
||||
try {
|
||||
// 解码文件路径
|
||||
const filePath = Buffer.from(file_id, 'base64').toString();
|
||||
const filename = path.basename(filePath);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw ErrorTypes.NOT_FOUND('文件不存在');
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
const deleted = await deleteFile(filePath);
|
||||
|
||||
if (!deleted) {
|
||||
throw ErrorTypes.INTERNAL_ERROR('文件删除失败');
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
logBusinessOperation('file_deleted', 'file', {
|
||||
fileId: file_id,
|
||||
filename,
|
||||
filePath
|
||||
}, req.user);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '文件删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logError(error, {
|
||||
type: 'file_deletion_error',
|
||||
userId: req.user?.id,
|
||||
fileId: file_id
|
||||
});
|
||||
throw ErrorTypes.INTERNAL_ERROR('删除文件失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 批量删除文件
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
const batchDeleteFiles = catchAsync(async (req, res) => {
|
||||
const { file_ids } = req.body;
|
||||
|
||||
if (!Array.isArray(file_ids) || file_ids.length === 0) {
|
||||
throw ErrorTypes.VALIDATION_ERROR('请提供要删除的文件ID列表');
|
||||
}
|
||||
|
||||
if (file_ids.length > 50) {
|
||||
throw ErrorTypes.VALIDATION_ERROR('单次最多删除50个文件');
|
||||
}
|
||||
|
||||
const results = {
|
||||
success: [],
|
||||
failed: []
|
||||
};
|
||||
|
||||
for (const file_id of file_ids) {
|
||||
try {
|
||||
// 解码文件路径
|
||||
const filePath = Buffer.from(file_id, 'base64').toString();
|
||||
const filename = path.basename(filePath);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
const deleted = await deleteFile(filePath);
|
||||
|
||||
if (deleted) {
|
||||
results.success.push({
|
||||
file_id,
|
||||
filename,
|
||||
message: '删除成功'
|
||||
});
|
||||
} else {
|
||||
results.failed.push({
|
||||
file_id,
|
||||
filename,
|
||||
message: '删除失败'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
results.failed.push({
|
||||
file_id,
|
||||
filename: '未知',
|
||||
message: '文件不存在'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed.push({
|
||||
file_id,
|
||||
filename: '未知',
|
||||
message: error.message || '删除失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
logBusinessOperation('files_batch_deleted', 'file', {
|
||||
totalFiles: file_ids.length,
|
||||
successCount: results.success.length,
|
||||
failedCount: results.failed.length
|
||||
}, req.user);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `批量删除完成,成功: ${results.success.length},失败: ${results.failed.length}`,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取文件统计信息
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
const getFileStatistics = catchAsync(async (req, res) => {
|
||||
const uploadDir = path.join(__dirname, '../../../uploads');
|
||||
|
||||
try {
|
||||
const typeDirs = {
|
||||
avatar: path.join(uploadDir, 'avatars'),
|
||||
animal: path.join(uploadDir, 'animals'),
|
||||
travel: path.join(uploadDir, 'travels'),
|
||||
document: path.join(uploadDir, 'documents')
|
||||
};
|
||||
|
||||
const stats = {
|
||||
totalFiles: 0,
|
||||
totalSize: 0,
|
||||
typeStats: [],
|
||||
sizeDistribution: {
|
||||
small: 0, // < 1MB
|
||||
medium: 0, // 1MB - 5MB
|
||||
large: 0 // > 5MB
|
||||
},
|
||||
formatStats: {}
|
||||
};
|
||||
|
||||
for (const [type, dir] of Object.entries(typeDirs)) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
stats.typeStats.push({
|
||||
type,
|
||||
count: 0,
|
||||
size: 0,
|
||||
avgSize: 0
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(dir);
|
||||
let typeCount = 0;
|
||||
let typeSize = 0;
|
||||
|
||||
for (const file of files) {
|
||||
// 跳过缩略图文件
|
||||
if (file.includes('_thumb')) continue;
|
||||
|
||||
const filePath = path.join(dir, file);
|
||||
const fileStat = fs.statSync(filePath);
|
||||
const fileSize = fileStat.size;
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
|
||||
typeCount++;
|
||||
typeSize += fileSize;
|
||||
stats.totalFiles++;
|
||||
stats.totalSize += fileSize;
|
||||
|
||||
// 大小分布统计
|
||||
if (fileSize < 1024 * 1024) {
|
||||
stats.sizeDistribution.small++;
|
||||
} else if (fileSize < 5 * 1024 * 1024) {
|
||||
stats.sizeDistribution.medium++;
|
||||
} else {
|
||||
stats.sizeDistribution.large++;
|
||||
}
|
||||
|
||||
// 格式统计
|
||||
if (!stats.formatStats[ext]) {
|
||||
stats.formatStats[ext] = { count: 0, size: 0 };
|
||||
}
|
||||
stats.formatStats[ext].count++;
|
||||
stats.formatStats[ext].size += fileSize;
|
||||
}
|
||||
|
||||
stats.typeStats.push({
|
||||
type,
|
||||
count: typeCount,
|
||||
size: typeSize,
|
||||
avgSize: typeCount > 0 ? Math.round(typeSize / typeCount) : 0
|
||||
});
|
||||
}
|
||||
|
||||
// 转换格式统计为数组
|
||||
const formatStatsArray = Object.entries(stats.formatStats).map(([format, data]) => ({
|
||||
format,
|
||||
count: data.count,
|
||||
size: data.size,
|
||||
percentage: ((data.count / stats.totalFiles) * 100).toFixed(2)
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
|
||||
stats.formatStats = formatStatsArray;
|
||||
|
||||
// 记录操作日志
|
||||
logBusinessOperation('file_statistics_viewed', 'file', {
|
||||
totalFiles: stats.totalFiles,
|
||||
totalSize: stats.totalSize
|
||||
}, req.user);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取成功',
|
||||
data: stats
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, {
|
||||
type: 'file_statistics_error',
|
||||
userId: req.user?.id
|
||||
});
|
||||
throw ErrorTypes.INTERNAL_ERROR('获取文件统计失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 清理无用文件
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
const cleanupUnusedFiles = catchAsync(async (req, res) => {
|
||||
const { dry_run = true } = req.query;
|
||||
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '../../../uploads');
|
||||
const typeDirs = {
|
||||
avatar: path.join(uploadDir, 'avatars'),
|
||||
animal: path.join(uploadDir, 'animals'),
|
||||
travel: path.join(uploadDir, 'travels'),
|
||||
document: path.join(uploadDir, 'documents')
|
||||
};
|
||||
|
||||
const results = {
|
||||
scanned: 0,
|
||||
unused: [],
|
||||
deleted: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
// 这里应该根据实际业务逻辑检查文件是否被使用
|
||||
// 目前只是示例,检查30天前的文件
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const [type, dir] of Object.entries(typeDirs)) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
for (const file of files) {
|
||||
// 跳过缩略图文件
|
||||
if (file.includes('_thumb')) continue;
|
||||
|
||||
const filePath = path.join(dir, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
results.scanned++;
|
||||
|
||||
// 检查文件是否超过30天且未被使用(这里需要根据实际业务逻辑实现)
|
||||
if (stats.mtime < thirtyDaysAgo) {
|
||||
results.unused.push({
|
||||
filename: file,
|
||||
type,
|
||||
size: stats.size,
|
||||
lastModified: stats.mtime
|
||||
});
|
||||
|
||||
// 如果不是试运行,则删除文件
|
||||
if (dry_run !== 'true') {
|
||||
try {
|
||||
const deleted = await deleteFile(filePath);
|
||||
if (deleted) {
|
||||
results.deleted.push({
|
||||
filename: file,
|
||||
type,
|
||||
size: stats.size
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
filename: file,
|
||||
type,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
logBusinessOperation('file_cleanup', 'file', {
|
||||
dryRun: dry_run === 'true',
|
||||
scanned: results.scanned,
|
||||
unused: results.unused.length,
|
||||
deleted: results.deleted.length,
|
||||
errors: results.errors.length
|
||||
}, req.user);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: dry_run === 'true' ? '扫描完成(试运行)' : '清理完成',
|
||||
data: results
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, {
|
||||
type: 'file_cleanup_error',
|
||||
userId: req.user?.id,
|
||||
dryRun: dry_run === 'true'
|
||||
});
|
||||
throw ErrorTypes.INTERNAL_ERROR('文件清理失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
const uploadFile = catchAsync(async (req, res) => {
|
||||
if (!req.file && !req.files) {
|
||||
throw ErrorTypes.VALIDATION_ERROR('请选择要上传的文件');
|
||||
}
|
||||
|
||||
const files = req.files || [req.file];
|
||||
const uploadedFiles = [];
|
||||
|
||||
for (const file of files) {
|
||||
const fileInfo = {
|
||||
id: Buffer.from(file.path).toString('base64'),
|
||||
filename: file.filename,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
mimetype: file.mimetype,
|
||||
url: file.path.replace(path.join(__dirname, '../../../'), '/'),
|
||||
thumbnailUrl: file.thumbnail ? file.path.replace(path.basename(file.path), file.thumbnail).replace(path.join(__dirname, '../../../'), '/') : null,
|
||||
created_at: new Date()
|
||||
};
|
||||
|
||||
uploadedFiles.push(fileInfo);
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
logBusinessOperation('files_uploaded', 'file', {
|
||||
fileCount: uploadedFiles.length,
|
||||
files: uploadedFiles.map(f => ({
|
||||
filename: f.filename,
|
||||
size: f.size,
|
||||
mimetype: f.mimetype
|
||||
}))
|
||||
}, req.user);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '文件上传成功',
|
||||
data: {
|
||||
files: uploadedFiles
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getFileList,
|
||||
getFileDetail,
|
||||
deleteFileById,
|
||||
batchDeleteFiles,
|
||||
getFileStatistics,
|
||||
cleanupUnusedFiles,
|
||||
uploadFile
|
||||
};
|
||||
487
backend/src/controllers/admin/userManagement.js
Normal file
487
backend/src/controllers/admin/userManagement.js
Normal file
@@ -0,0 +1,487 @@
|
||||
// 管理员用户管理控制器
|
||||
const User = require('../../models/user');
|
||||
const UserService = require('../../services/user');
|
||||
const { query } = require('../../config/database');
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.getUserList = async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
keyword = '',
|
||||
userType = '',
|
||||
status = '',
|
||||
startDate = '',
|
||||
endDate = '',
|
||||
sortField = 'created_at',
|
||||
sortOrder = 'desc'
|
||||
} = req.query;
|
||||
|
||||
// 构建查询条件
|
||||
let whereClause = 'WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
// 关键词搜索
|
||||
if (keyword) {
|
||||
whereClause += ' AND (nickname LIKE ? OR phone LIKE ? OR email LIKE ?)';
|
||||
const searchTerm = `%${keyword}%`;
|
||||
params.push(searchTerm, searchTerm, searchTerm);
|
||||
}
|
||||
|
||||
// 用户类型筛选
|
||||
if (userType) {
|
||||
whereClause += ' AND user_type = ?';
|
||||
params.push(userType);
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if (status) {
|
||||
whereClause += ' AND status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// 日期范围筛选
|
||||
if (startDate) {
|
||||
whereClause += ' AND created_at >= ?';
|
||||
params.push(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
whereClause += ' AND created_at <= ?';
|
||||
params.push(endDate + ' 23:59:59');
|
||||
}
|
||||
|
||||
// 计算总数
|
||||
const countSql = `SELECT COUNT(*) as total FROM users ${whereClause}`;
|
||||
const countResult = await query(countSql, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 分页查询
|
||||
const offset = (page - 1) * pageSize;
|
||||
const orderBy = `ORDER BY ${sortField} ${sortOrder.toUpperCase()}`;
|
||||
const listSql = `
|
||||
SELECT
|
||||
id, nickname, phone, email, user_type, status,
|
||||
travel_count, animal_claim_count, points, level,
|
||||
last_login_at, created_at, updated_at
|
||||
FROM users
|
||||
${whereClause}
|
||||
${orderBy}
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
const listParams = [...params, parseInt(pageSize), offset];
|
||||
const users = await query(listSql, listParams);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
users,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.getUserDetail = async (req, res, next) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// 获取用户基本信息
|
||||
const userSql = `
|
||||
SELECT
|
||||
id, openid, nickname, avatar, gender, birthday, phone, email,
|
||||
province, city, travel_count, animal_claim_count, points, level,
|
||||
status, last_login_at, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`;
|
||||
const userResult = await query(userSql, [userId]);
|
||||
|
||||
if (userResult.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
code: 404,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
const user = userResult[0];
|
||||
|
||||
// 获取用户兴趣
|
||||
const interestsSql = `
|
||||
SELECT ui.interest_name, ui.created_at
|
||||
FROM user_interests ui
|
||||
WHERE ui.user_id = ?
|
||||
`;
|
||||
const interests = await query(interestsSql, [userId]);
|
||||
|
||||
// 获取用户最近的旅行记录
|
||||
const travelsSql = `
|
||||
SELECT id, title, destination, start_date, end_date, status, created_at
|
||||
FROM travels
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
const travels = await query(travelsSql, [userId]);
|
||||
|
||||
// 获取用户最近的认领记录
|
||||
const claimsSql = `
|
||||
SELECT ac.id, a.name as animal_name, ac.status, ac.created_at
|
||||
FROM animal_claims ac
|
||||
JOIN animals a ON ac.animal_id = a.id
|
||||
WHERE ac.user_id = ?
|
||||
ORDER BY ac.created_at DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
const claims = await query(claimsSql, [userId]);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
user: {
|
||||
...user,
|
||||
interests: interests.map(i => i.interest_name),
|
||||
recentTravels: travels,
|
||||
recentClaims: claims
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新用户状态
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.updateUserStatus = async (req, res, next) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const { status, reason } = req.body;
|
||||
|
||||
// 验证状态值
|
||||
const validStatuses = ['active', 'inactive', 'banned'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
code: 400,
|
||||
message: '无效的状态值'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
const checkSql = 'SELECT id, status FROM users WHERE id = ?';
|
||||
const checkResult = await query(checkSql, [userId]);
|
||||
|
||||
if (checkResult.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
code: 404,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 更新用户状态
|
||||
const updateSql = 'UPDATE users SET status = ?, updated_at = NOW() WHERE id = ?';
|
||||
await query(updateSql, [status, userId]);
|
||||
|
||||
// 记录操作日志
|
||||
const logSql = `
|
||||
INSERT INTO admin_operation_logs (admin_id, operation_type, target_type, target_id, operation_detail, created_at)
|
||||
VALUES (?, 'update_user_status', 'user', ?, ?, NOW())
|
||||
`;
|
||||
const operationDetail = JSON.stringify({
|
||||
old_status: checkResult[0].status,
|
||||
new_status: status,
|
||||
reason: reason || '无'
|
||||
});
|
||||
await query(logSql, [req.admin.id, userId, operationDetail]);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '状态更新成功'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量更新用户状态
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.batchUpdateUserStatus = async (req, res, next) => {
|
||||
try {
|
||||
const { userIds, status, reason } = req.body;
|
||||
|
||||
// 验证输入
|
||||
if (!Array.isArray(userIds) || userIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
code: 400,
|
||||
message: '用户ID列表不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const validStatuses = ['active', 'inactive', 'banned'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
code: 400,
|
||||
message: '无效的状态值'
|
||||
});
|
||||
}
|
||||
|
||||
// 批量更新
|
||||
const placeholders = userIds.map(() => '?').join(',');
|
||||
const updateSql = `UPDATE users SET status = ?, updated_at = NOW() WHERE id IN (${placeholders})`;
|
||||
const updateParams = [status, ...userIds];
|
||||
|
||||
const result = await query(updateSql, updateParams);
|
||||
|
||||
// 记录操作日志
|
||||
const logSql = `
|
||||
INSERT INTO admin_operation_logs (admin_id, operation_type, target_type, target_id, operation_detail, created_at)
|
||||
VALUES (?, 'batch_update_user_status', 'user', ?, ?, NOW())
|
||||
`;
|
||||
const operationDetail = JSON.stringify({
|
||||
user_ids: userIds,
|
||||
new_status: status,
|
||||
reason: reason || '无',
|
||||
affected_rows: result.affectedRows
|
||||
});
|
||||
await query(logSql, [req.admin.id, 0, operationDetail]);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: `成功更新 ${result.affectedRows} 个用户的状态`
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户统计信息
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.getUserStatistics = async (req, res, next) => {
|
||||
try {
|
||||
const { period = '7d' } = req.query;
|
||||
|
||||
// 基础统计
|
||||
const basicStatsSql = `
|
||||
SELECT
|
||||
COUNT(*) as total_users,
|
||||
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_users,
|
||||
COUNT(CASE WHEN status = 'inactive' THEN 1 END) as inactive_users,
|
||||
COUNT(CASE WHEN status = 'banned' THEN 1 END) as banned_users,
|
||||
COUNT(CASE WHEN user_type = 'farmer' THEN 1 END) as farmers,
|
||||
COUNT(CASE WHEN user_type = 'merchant' THEN 1 END) as merchants,
|
||||
COUNT(CASE WHEN DATE(created_at) = CURDATE() THEN 1 END) as new_users_today,
|
||||
COUNT(CASE WHEN DATE(created_at) >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN 1 END) as new_users_week,
|
||||
COUNT(CASE WHEN DATE(created_at) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN 1 END) as new_users_month
|
||||
FROM users
|
||||
`;
|
||||
const basicStats = await query(basicStatsSql);
|
||||
|
||||
// 用户等级分布
|
||||
const levelStatsSql = `
|
||||
SELECT
|
||||
level,
|
||||
COUNT(*) as count
|
||||
FROM users
|
||||
GROUP BY level
|
||||
`;
|
||||
const levelStats = await query(levelStatsSql);
|
||||
|
||||
// 根据时间周期获取趋势数据
|
||||
let trendSql;
|
||||
let trendDays;
|
||||
|
||||
switch (period) {
|
||||
case '30d':
|
||||
trendDays = 30;
|
||||
break;
|
||||
case '90d':
|
||||
trendDays = 90;
|
||||
break;
|
||||
default:
|
||||
trendDays = 7;
|
||||
}
|
||||
|
||||
trendSql = `
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as new_users,
|
||||
COUNT(CASE WHEN user_type = 'farmer' THEN 1 END) as new_farmers,
|
||||
COUNT(CASE WHEN user_type = 'merchant' THEN 1 END) as new_merchants
|
||||
FROM users
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ${trendDays} DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
const trendData = await query(trendSql);
|
||||
|
||||
// 活跃用户统计(最近30天有登录的用户)
|
||||
const activeUsersSql = `
|
||||
SELECT COUNT(*) as active_users_30d
|
||||
FROM users
|
||||
WHERE last_login_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
`;
|
||||
const activeUsersResult = await query(activeUsersSql);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
basicStats: basicStats[0],
|
||||
levelDistribution: levelStats,
|
||||
trendData,
|
||||
activeUsers30d: activeUsersResult[0].active_users_30d
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出用户数据
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
exports.exportUsers = async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
format = 'csv',
|
||||
userType = '',
|
||||
status = '',
|
||||
startDate = '',
|
||||
endDate = ''
|
||||
} = req.query;
|
||||
|
||||
// 构建查询条件
|
||||
let whereClause = 'WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (userType) {
|
||||
whereClause += ' AND user_type = ?';
|
||||
params.push(userType);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
whereClause += ' AND status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
whereClause += ' AND created_at >= ?';
|
||||
params.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
whereClause += ' AND created_at <= ?';
|
||||
params.push(endDate + ' 23:59:59');
|
||||
}
|
||||
|
||||
// 查询用户数据
|
||||
const exportSql = `
|
||||
SELECT
|
||||
id, nickname, phone, email, user_type, status,
|
||||
travel_count, animal_claim_count, points, level,
|
||||
created_at, last_login_at
|
||||
FROM users
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
const users = await query(exportSql, params);
|
||||
|
||||
if (format === 'csv') {
|
||||
// 生成CSV格式
|
||||
const csvHeader = 'ID,昵称,手机号,邮箱,用户类型,状态,旅行次数,认领次数,积分,等级,注册时间,最后登录\n';
|
||||
const csvData = users.map(user => {
|
||||
return [
|
||||
user.id,
|
||||
user.nickname || '',
|
||||
user.phone || '',
|
||||
user.email || '',
|
||||
user.user_type || '',
|
||||
user.status,
|
||||
user.travel_count,
|
||||
user.animal_claim_count,
|
||||
user.points,
|
||||
user.level,
|
||||
user.created_at,
|
||||
user.last_login_at || ''
|
||||
].join(',');
|
||||
}).join('\n');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=users_${Date.now()}.csv`);
|
||||
res.send('\uFEFF' + csvHeader + csvData); // 添加BOM以支持中文
|
||||
} else {
|
||||
// 返回JSON格式
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
code: 200,
|
||||
message: '导出成功',
|
||||
data: {
|
||||
users,
|
||||
total: users.length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
const logSql = `
|
||||
INSERT INTO admin_operation_logs (admin_id, operation_type, target_type, target_id, operation_detail, created_at)
|
||||
VALUES (?, 'export_users', 'user', ?, ?, NOW())
|
||||
`;
|
||||
const operationDetail = JSON.stringify({
|
||||
format,
|
||||
filters: { userType, status, startDate, endDate },
|
||||
exported_count: users.length
|
||||
});
|
||||
await query(logSql, [req.admin.id, 0, operationDetail]);
|
||||
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
438
backend/src/controllers/animalClaim.js
Normal file
438
backend/src/controllers/animalClaim.js
Normal file
@@ -0,0 +1,438 @@
|
||||
const AnimalClaimService = require('../services/animalClaim');
|
||||
const { validateRequired, validatePositiveInteger } = require('../utils/validation');
|
||||
|
||||
class AnimalClaimController {
|
||||
/**
|
||||
* 申请认领动物
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async createClaim(req, res) {
|
||||
try {
|
||||
const { animal_id, claim_reason, claim_duration, contact_info } = req.body;
|
||||
const user_id = req.user.id;
|
||||
|
||||
// 参数验证
|
||||
if (!validateRequired(animal_id) || !validatePositiveInteger(animal_id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '动物ID不能为空且必须为正整数'
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateRequired(contact_info)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '联系方式不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
if (claim_duration && (!validatePositiveInteger(claim_duration) || claim_duration < 1 || claim_duration > 60)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '认领时长必须为1-60个月之间的整数'
|
||||
});
|
||||
}
|
||||
|
||||
// 创建认领申请
|
||||
const claim = await AnimalClaimService.createClaim({
|
||||
animal_id: parseInt(animal_id),
|
||||
user_id,
|
||||
claim_reason,
|
||||
claim_duration: claim_duration ? parseInt(claim_duration) : 12,
|
||||
contact_info
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '认领申请提交成功',
|
||||
data: claim
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建认领申请控制器错误:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || '创建认领申请失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消认领申请
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async cancelClaim(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const user_id = req.user.id;
|
||||
|
||||
// 参数验证
|
||||
if (!validatePositiveInteger(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '认领申请ID无效'
|
||||
});
|
||||
}
|
||||
|
||||
// 取消认领申请
|
||||
const claim = await AnimalClaimService.cancelClaim(parseInt(id), user_id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '认领申请已取消',
|
||||
data: claim
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('取消认领申请控制器错误:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || '取消认领申请失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的认领申请列表
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async getUserClaims(req, res) {
|
||||
try {
|
||||
const user_id = req.user.id;
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status,
|
||||
animal_type,
|
||||
start_date,
|
||||
end_date
|
||||
} = req.query;
|
||||
|
||||
// 参数验证
|
||||
if (!validatePositiveInteger(page) || !validatePositiveInteger(limit)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '分页参数必须为正整数'
|
||||
});
|
||||
}
|
||||
|
||||
if (parseInt(limit) > 100) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '每页数量不能超过100'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取认领申请列表
|
||||
const result = await AnimalClaimService.getUserClaims(user_id, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
status,
|
||||
animal_type,
|
||||
start_date,
|
||||
end_date
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取认领申请列表成功',
|
||||
data: result.data,
|
||||
pagination: result.pagination
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取用户认领申请列表控制器错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取认领申请列表失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动物的认领申请列表
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async getAnimalClaims(req, res) {
|
||||
try {
|
||||
const { animal_id } = req.params;
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status
|
||||
} = req.query;
|
||||
|
||||
// 参数验证
|
||||
if (!validatePositiveInteger(animal_id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '动物ID无效'
|
||||
});
|
||||
}
|
||||
|
||||
if (!validatePositiveInteger(page) || !validatePositiveInteger(limit)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '分页参数必须为正整数'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取动物认领申请列表
|
||||
const result = await AnimalClaimService.getAnimalClaims(parseInt(animal_id), {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
status
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取动物认领申请列表成功',
|
||||
data: result.data,
|
||||
pagination: result.pagination
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取动物认领申请列表控制器错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取动物认领申请列表失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有认领申请列表(管理员)
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async getAllClaims(req, res) {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status,
|
||||
animal_type,
|
||||
user_id,
|
||||
start_date,
|
||||
end_date,
|
||||
keyword
|
||||
} = req.query;
|
||||
|
||||
// 参数验证
|
||||
if (!validatePositiveInteger(page) || !validatePositiveInteger(limit)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '分页参数必须为正整数'
|
||||
});
|
||||
}
|
||||
|
||||
if (parseInt(limit) > 100) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '每页数量不能超过100'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有认领申请列表
|
||||
const result = await AnimalClaimService.getAllClaims({
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
status,
|
||||
animal_type,
|
||||
user_id: user_id ? parseInt(user_id) : undefined,
|
||||
start_date,
|
||||
end_date,
|
||||
keyword
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取认领申请列表成功',
|
||||
data: result.data,
|
||||
pagination: result.pagination
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取所有认领申请列表控制器错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取认领申请列表失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核认领申请
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async reviewClaim(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, review_remark } = req.body;
|
||||
const reviewed_by = req.user.id;
|
||||
|
||||
// 参数验证
|
||||
if (!validatePositiveInteger(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '认领申请ID无效'
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateRequired(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '审核状态不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const validStatuses = ['approved', 'rejected'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的审核状态'
|
||||
});
|
||||
}
|
||||
|
||||
// 审核认领申请
|
||||
const claim = await AnimalClaimService.reviewClaim(parseInt(id), status, {
|
||||
reviewed_by,
|
||||
review_remark
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `认领申请${status === 'approved' ? '审核通过' : '审核拒绝'}`,
|
||||
data: claim
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('审核认领申请控制器错误:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || '审核认领申请失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 续期认领
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async renewClaim(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { duration, payment_method } = req.body;
|
||||
const user_id = req.user.id;
|
||||
|
||||
// 参数验证
|
||||
if (!validatePositiveInteger(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '认领申请ID无效'
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateRequired(duration) || !validatePositiveInteger(duration) || duration < 1 || duration > 60) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '续期时长必须为1-60个月之间的整数'
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateRequired(payment_method)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '支付方式不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
// 续期认领
|
||||
const result = await AnimalClaimService.renewClaim(parseInt(id), user_id, {
|
||||
duration: parseInt(duration),
|
||||
payment_method
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('续期认领控制器错误:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || '续期认领失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认领统计信息
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async getClaimStatistics(req, res) {
|
||||
try {
|
||||
const { start_date, end_date, animal_type } = req.query;
|
||||
|
||||
// 获取认领统计信息
|
||||
const statistics = await AnimalClaimService.getClaimStatistics({
|
||||
start_date,
|
||||
end_date,
|
||||
animal_type
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '获取认领统计信息成功',
|
||||
data: statistics
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取认领统计信息控制器错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取认领统计信息失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查认领权限
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async checkClaimPermission(req, res) {
|
||||
try {
|
||||
const { animal_id } = req.params;
|
||||
const user_id = req.user.id;
|
||||
|
||||
// 参数验证
|
||||
if (!validatePositiveInteger(animal_id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '动物ID无效'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查认领权限
|
||||
const hasPermission = await AnimalClaimService.checkClaimPermission(user_id, parseInt(animal_id));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '检查认领权限成功',
|
||||
data: {
|
||||
can_claim: hasPermission
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('检查认领权限控制器错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '检查认领权限失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AnimalClaimController();
|
||||
@@ -1,8 +1,10 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const UserMySQL = require('../models/UserMySQL');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { success } = require('../utils/response');
|
||||
const { sendEmail } = require('../utils/email');
|
||||
|
||||
// 生成JWT Token
|
||||
const generateToken = (userId) => {
|
||||
@@ -13,6 +15,20 @@ const generateToken = (userId) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 生成刷新Token
|
||||
const generateRefreshToken = (userId) => {
|
||||
return jwt.sign(
|
||||
{ userId, type: 'refresh' },
|
||||
process.env.JWT_REFRESH_SECRET || 'your-refresh-secret-key',
|
||||
{ expiresIn: process.env.JWT_REFRESH_EXPIRE || '30d' }
|
||||
);
|
||||
};
|
||||
|
||||
// 生成验证码
|
||||
const generateVerificationCode = () => {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
};
|
||||
|
||||
// 用户注册
|
||||
const register = async (req, res, next) => {
|
||||
try {
|
||||
@@ -50,8 +66,9 @@ const register = async (req, res, next) => {
|
||||
// 获取用户信息
|
||||
const user = await UserMySQL.findById(userId);
|
||||
|
||||
// 生成token
|
||||
// 生成token和刷新token
|
||||
const token = generateToken(userId);
|
||||
const refreshToken = generateRefreshToken(userId);
|
||||
|
||||
// 更新最后登录时间
|
||||
await UserMySQL.updateLastLogin(userId);
|
||||
@@ -59,6 +76,7 @@ const register = async (req, res, next) => {
|
||||
res.status(201).json(success({
|
||||
user: UserMySQL.sanitize(user),
|
||||
token,
|
||||
refreshToken,
|
||||
message: '注册成功'
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -99,8 +117,9 @@ const login = async (req, res, next) => {
|
||||
throw new AppError('密码错误', 401);
|
||||
}
|
||||
|
||||
// 生成token
|
||||
// 生成token和刷新token
|
||||
const token = generateToken(user.id);
|
||||
const refreshToken = generateRefreshToken(user.id);
|
||||
|
||||
// 更新最后登录时间
|
||||
await UserMySQL.updateLastLogin(user.id);
|
||||
@@ -108,6 +127,7 @@ const login = async (req, res, next) => {
|
||||
res.json(success({
|
||||
user: UserMySQL.sanitize(user),
|
||||
token,
|
||||
refreshToken,
|
||||
message: '登录成功'
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -307,6 +327,178 @@ const adminLogin = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新Token
|
||||
const refreshToken = async (req, res, next) => {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new AppError('刷新token不能为空', 400);
|
||||
}
|
||||
|
||||
// 验证刷新token
|
||||
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || 'your-refresh-secret-key');
|
||||
|
||||
if (decoded.type !== 'refresh') {
|
||||
throw new AppError('无效的刷新token', 401);
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = await UserMySQL.findById(decoded.userId);
|
||||
if (!user) {
|
||||
throw new AppError('用户不存在', 404);
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (!UserMySQL.isActive(user)) {
|
||||
throw new AppError('账户已被禁用', 403);
|
||||
}
|
||||
|
||||
// 生成新的访问token
|
||||
const newToken = generateToken(user.id);
|
||||
|
||||
res.json(success({
|
||||
token: newToken,
|
||||
message: 'Token刷新成功'
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
throw new AppError('无效的刷新token', 401);
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
throw new AppError('刷新token已过期', 401);
|
||||
}
|
||||
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 发送邮箱验证码
|
||||
const sendEmailVerification = async (req, res, next) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
throw new AppError('邮箱不能为空', 400);
|
||||
}
|
||||
|
||||
// 检查邮箱格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
throw new AppError('邮箱格式不正确', 400);
|
||||
}
|
||||
|
||||
// 生成验证码
|
||||
const verificationCode = generateVerificationCode();
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10分钟后过期
|
||||
|
||||
// 保存验证码到数据库(这里需要创建一个验证码表)
|
||||
await UserMySQL.saveVerificationCode(email, verificationCode, expiresAt);
|
||||
|
||||
// 发送邮件
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: '结伴客 - 邮箱验证',
|
||||
html: `
|
||||
<h2>邮箱验证</h2>
|
||||
<p>您的验证码是:<strong>${verificationCode}</strong></p>
|
||||
<p>验证码将在10分钟后过期,请及时使用。</p>
|
||||
<p>如果这不是您的操作,请忽略此邮件。</p>
|
||||
`
|
||||
});
|
||||
|
||||
res.json(success({
|
||||
message: '验证码已发送到您的邮箱'
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 忘记密码
|
||||
const forgotPassword = async (req, res, next) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
throw new AppError('邮箱不能为空', 400);
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = await UserMySQL.findByEmail(email);
|
||||
if (!user) {
|
||||
// 为了安全,不暴露用户是否存在
|
||||
res.json(success({
|
||||
message: '如果该邮箱已注册,重置密码链接已发送到您的邮箱'
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成重置token
|
||||
const resetToken = generateVerificationCode();
|
||||
const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 30分钟后过期
|
||||
|
||||
// 保存重置token
|
||||
await UserMySQL.savePasswordResetToken(user.id, resetToken, expiresAt);
|
||||
|
||||
// 发送重置邮件
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: '结伴客 - 密码重置',
|
||||
html: `
|
||||
<h2>密码重置</h2>
|
||||
<p>您请求重置密码,请点击下面的链接重置您的密码:</p>
|
||||
<a href="${process.env.FRONTEND_URL}/reset-password?token=${resetToken}">重置密码</a>
|
||||
<p>此链接将在30分钟后过期。</p>
|
||||
<p>如果这不是您的操作,请忽略此邮件。</p>
|
||||
`
|
||||
});
|
||||
|
||||
res.json(success({
|
||||
message: '如果该邮箱已注册,重置密码链接已发送到您的邮箱'
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置密码
|
||||
const resetPassword = async (req, res, next) => {
|
||||
try {
|
||||
const { token, newPassword } = req.body;
|
||||
|
||||
if (!token || !newPassword) {
|
||||
throw new AppError('重置token和新密码不能为空', 400);
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
throw new AppError('密码长度不能少于6位', 400);
|
||||
}
|
||||
|
||||
// 验证重置token
|
||||
const resetData = await UserMySQL.findPasswordResetToken(token);
|
||||
if (!resetData || new Date() > resetData.expires_at) {
|
||||
throw new AppError('重置token无效或已过期', 400);
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// 更新密码
|
||||
await UserMySQL.updatePassword(resetData.user_id, hashedPassword);
|
||||
|
||||
// 删除重置token
|
||||
await UserMySQL.deletePasswordResetToken(token);
|
||||
|
||||
res.json(success({
|
||||
message: '密码重置成功'
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
register,
|
||||
login,
|
||||
@@ -314,5 +506,9 @@ module.exports = {
|
||||
updateProfile,
|
||||
changePassword,
|
||||
wechatLogin,
|
||||
adminLogin
|
||||
adminLogin,
|
||||
refreshToken,
|
||||
sendEmailVerification,
|
||||
forgotPassword,
|
||||
resetPassword
|
||||
};
|
||||
@@ -191,44 +191,43 @@ async function cancelOrder(req, res, next) {
|
||||
async function payOrder(req, res, next) {
|
||||
try {
|
||||
const { orderId } = req.params;
|
||||
const userId = req.user.id;
|
||||
const paymentData = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证必要字段
|
||||
if (!paymentData.payment_method) {
|
||||
if (!paymentData.payment_method || !paymentData.amount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '缺少必要字段: payment_method'
|
||||
message: '缺少必要字段: payment_method, amount'
|
||||
});
|
||||
}
|
||||
|
||||
const order = await OrderService.payOrder(orderId, userId, paymentData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '订单支付成功',
|
||||
data: order
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('支付订单控制器错误:', error);
|
||||
if (error.message === '订单不存在') {
|
||||
// 获取订单并验证权限
|
||||
const order = await OrderService.getOrderById(orderId);
|
||||
if (!order) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '订单不存在'
|
||||
});
|
||||
}
|
||||
if (error.message === '无权操作此订单') {
|
||||
|
||||
// 检查权限:用户只能支付自己的订单
|
||||
if (req.user.role === 'user' && order.user_id !== userId) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权操作此订单'
|
||||
});
|
||||
}
|
||||
if (error.message === '订单状态不允许支付') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '订单状态不允许支付'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await OrderService.payOrder(orderId, paymentData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '支付订单创建成功',
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('支付订单控制器错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '支付订单失败'
|
||||
|
||||
371
backend/src/controllers/payment.js
Normal file
371
backend/src/controllers/payment.js
Normal file
@@ -0,0 +1,371 @@
|
||||
const PaymentService = require('../services/payment');
|
||||
const { validationResult } = require('express-validator');
|
||||
|
||||
class PaymentController {
|
||||
/**
|
||||
* 创建支付订单
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async createPayment(req, res) {
|
||||
try {
|
||||
// 验证请求参数
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '参数验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const paymentData = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证必要字段
|
||||
if (!paymentData.order_id || !paymentData.amount || !paymentData.payment_method) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '缺少必要字段: order_id, amount, payment_method'
|
||||
});
|
||||
}
|
||||
|
||||
// 添加用户ID
|
||||
paymentData.user_id = userId;
|
||||
|
||||
const payment = await PaymentService.createPayment(paymentData);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '支付订单创建成功',
|
||||
data: payment
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建支付订单控制器错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '创建支付订单失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付订单详情
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async getPayment(req, res) {
|
||||
try {
|
||||
const { paymentId } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
const payment = await PaymentService.getPaymentById(paymentId);
|
||||
|
||||
// 检查权限:用户只能查看自己的支付订单
|
||||
if (req.user.role === 'user' && payment.user_id !== userId) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权访问此支付订单'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: payment
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取支付订单控制器错误:', error);
|
||||
if (error.message === '支付订单不存在') {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '支付订单不存在'
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取支付订单失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付状态
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async queryPaymentStatus(req, res) {
|
||||
try {
|
||||
const { paymentNo } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
const payment = await PaymentService.getPaymentByNo(paymentNo);
|
||||
|
||||
// 检查权限
|
||||
if (req.user.role === 'user' && payment.user_id !== userId) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权访问此支付订单'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
payment_no: payment.payment_no,
|
||||
status: payment.status,
|
||||
amount: payment.amount,
|
||||
paid_at: payment.paid_at,
|
||||
transaction_id: payment.transaction_id
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('查询支付状态控制器错误:', error);
|
||||
if (error.message === '支付订单不存在') {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '支付订单不存在'
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '查询支付状态失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付回调(微信支付)
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async handleWechatCallback(req, res) {
|
||||
try {
|
||||
const callbackData = req.body;
|
||||
|
||||
// 验证回调数据
|
||||
if (!callbackData.out_trade_no || !callbackData.transaction_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '回调数据不完整'
|
||||
});
|
||||
}
|
||||
|
||||
// 处理支付回调
|
||||
const payment = await PaymentService.handlePaymentCallback({
|
||||
payment_no: callbackData.out_trade_no,
|
||||
transaction_id: callbackData.transaction_id,
|
||||
status: callbackData.result_code === 'SUCCESS' ? 'paid' : 'failed',
|
||||
paid_amount: callbackData.total_fee / 100, // 微信金额单位为分
|
||||
paid_at: new Date()
|
||||
});
|
||||
|
||||
// 返回微信要求的格式
|
||||
res.set('Content-Type', 'application/xml');
|
||||
res.send(`
|
||||
<xml>
|
||||
<return_code><![CDATA[SUCCESS]]></return_code>
|
||||
<return_msg><![CDATA[OK]]></return_msg>
|
||||
</xml>
|
||||
`);
|
||||
} catch (error) {
|
||||
console.error('处理微信支付回调错误:', error);
|
||||
res.set('Content-Type', 'application/xml');
|
||||
res.send(`
|
||||
<xml>
|
||||
<return_code><![CDATA[FAIL]]></return_code>
|
||||
<return_msg><![CDATA[${error.message}]]></return_msg>
|
||||
</xml>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付回调(支付宝)
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async handleAlipayCallback(req, res) {
|
||||
try {
|
||||
const callbackData = req.body;
|
||||
|
||||
// 验证回调数据
|
||||
if (!callbackData.out_trade_no || !callbackData.trade_no) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '回调数据不完整'
|
||||
});
|
||||
}
|
||||
|
||||
// 处理支付回调
|
||||
const payment = await PaymentService.handlePaymentCallback({
|
||||
payment_no: callbackData.out_trade_no,
|
||||
transaction_id: callbackData.trade_no,
|
||||
status: callbackData.trade_status === 'TRADE_SUCCESS' ? 'paid' : 'failed',
|
||||
paid_amount: parseFloat(callbackData.total_amount),
|
||||
paid_at: new Date()
|
||||
});
|
||||
|
||||
res.send('success');
|
||||
} catch (error) {
|
||||
console.error('处理支付宝回调错误:', error);
|
||||
res.send('fail');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async createRefund(req, res) {
|
||||
try {
|
||||
const { paymentId } = req.params;
|
||||
const refundData = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证必要字段
|
||||
if (!refundData.refund_amount || !refundData.refund_reason) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '缺少必要字段: refund_amount, refund_reason'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取支付订单并验证权限
|
||||
const payment = await PaymentService.getPaymentById(paymentId);
|
||||
if (req.user.role === 'user' && payment.user_id !== userId) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权操作此支付订单'
|
||||
});
|
||||
}
|
||||
|
||||
const refund = await PaymentService.createRefund({
|
||||
payment_id: paymentId,
|
||||
refund_amount: refundData.refund_amount,
|
||||
refund_reason: refundData.refund_reason,
|
||||
user_id: userId
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '退款申请提交成功',
|
||||
data: refund
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('申请退款控制器错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '申请退款失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理退款(管理员)
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async processRefund(req, res) {
|
||||
try {
|
||||
const { refundId } = req.params;
|
||||
const { status, process_remark } = req.body;
|
||||
const adminId = req.user.id;
|
||||
|
||||
// 验证状态
|
||||
const validStatuses = ['approved', 'rejected', 'completed'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的退款状态'
|
||||
});
|
||||
}
|
||||
|
||||
const refund = await PaymentService.processRefund(refundId, status, {
|
||||
processed_by: adminId,
|
||||
process_remark
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '退款处理成功',
|
||||
data: refund
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('处理退款控制器错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '处理退款失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取退款详情
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async getRefund(req, res) {
|
||||
try {
|
||||
const { refundId } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
const refund = await PaymentService.getRefundById(refundId);
|
||||
|
||||
// 检查权限
|
||||
if (req.user.role === 'user' && refund.user_id !== userId) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权访问此退款记录'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: refund
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取退款详情控制器错误:', error);
|
||||
if (error.message === '退款记录不存在') {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '退款记录不存在'
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取退款详情失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付统计信息(管理员)
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
async getPaymentStatistics(req, res) {
|
||||
try {
|
||||
const filters = {
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date,
|
||||
payment_method: req.query.payment_method
|
||||
};
|
||||
|
||||
const statistics = await PaymentService.getPaymentStatistics(filters);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: statistics
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取支付统计控制器错误:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || '获取支付统计失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new PaymentController();
|
||||
163
backend/src/controllers/travelRegistration.js
Normal file
163
backend/src/controllers/travelRegistration.js
Normal file
@@ -0,0 +1,163 @@
|
||||
const TravelRegistrationService = require('../services/travelRegistration');
|
||||
const { success } = require('../utils/response');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
/**
|
||||
* 旅行活动报名控制器
|
||||
*/
|
||||
class TravelRegistrationController {
|
||||
/**
|
||||
* 报名参加旅行活动
|
||||
*/
|
||||
static async registerForTravel(req, res, next) {
|
||||
try {
|
||||
const { travelId } = req.params;
|
||||
const { message, emergencyContact, emergencyPhone } = req.body;
|
||||
const userId = req.userId;
|
||||
|
||||
if (!travelId) {
|
||||
throw new AppError('旅行活动ID不能为空', 400);
|
||||
}
|
||||
|
||||
const registration = await TravelRegistrationService.registerForTravel({
|
||||
userId,
|
||||
travelId: parseInt(travelId),
|
||||
message,
|
||||
emergencyContact,
|
||||
emergencyPhone
|
||||
});
|
||||
|
||||
res.json(success({
|
||||
registration,
|
||||
message: '报名成功,等待审核'
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消报名
|
||||
*/
|
||||
static async cancelRegistration(req, res, next) {
|
||||
try {
|
||||
const { registrationId } = req.params;
|
||||
const userId = req.userId;
|
||||
|
||||
if (!registrationId) {
|
||||
throw new AppError('报名记录ID不能为空', 400);
|
||||
}
|
||||
|
||||
await TravelRegistrationService.cancelRegistration(parseInt(registrationId), userId);
|
||||
|
||||
res.json(success({
|
||||
message: '取消报名成功'
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的报名记录
|
||||
*/
|
||||
static async getUserRegistrations(req, res, next) {
|
||||
try {
|
||||
const { page, pageSize, status } = req.query;
|
||||
const userId = req.userId;
|
||||
|
||||
const result = await TravelRegistrationService.getUserRegistrations({
|
||||
userId,
|
||||
page: parseInt(page) || 1,
|
||||
pageSize: parseInt(pageSize) || 10,
|
||||
status
|
||||
});
|
||||
|
||||
res.json(success(result));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旅行活动的报名列表(活动发起者可查看)
|
||||
*/
|
||||
static async getTravelRegistrations(req, res, next) {
|
||||
try {
|
||||
const { travelId } = req.params;
|
||||
const { page, pageSize, status } = req.query;
|
||||
const userId = req.userId;
|
||||
|
||||
if (!travelId) {
|
||||
throw new AppError('旅行活动ID不能为空', 400);
|
||||
}
|
||||
|
||||
const result = await TravelRegistrationService.getTravelRegistrations({
|
||||
travelId: parseInt(travelId),
|
||||
organizerId: userId,
|
||||
page: parseInt(page) || 1,
|
||||
pageSize: parseInt(pageSize) || 10,
|
||||
status
|
||||
});
|
||||
|
||||
res.json(success(result));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核报名申请(活动发起者操作)
|
||||
*/
|
||||
static async reviewRegistration(req, res, next) {
|
||||
try {
|
||||
const { registrationId } = req.params;
|
||||
const { action, rejectReason } = req.body;
|
||||
const userId = req.userId;
|
||||
|
||||
if (!registrationId) {
|
||||
throw new AppError('报名记录ID不能为空', 400);
|
||||
}
|
||||
|
||||
if (!['approve', 'reject'].includes(action)) {
|
||||
throw new AppError('操作类型无效', 400);
|
||||
}
|
||||
|
||||
const result = await TravelRegistrationService.reviewRegistration({
|
||||
registrationId: parseInt(registrationId),
|
||||
organizerId: userId,
|
||||
action,
|
||||
rejectReason
|
||||
});
|
||||
|
||||
res.json(success({
|
||||
registration: result,
|
||||
message: action === 'approve' ? '审核通过' : '已拒绝申请'
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取报名统计信息
|
||||
*/
|
||||
static async getRegistrationStats(req, res, next) {
|
||||
try {
|
||||
const { travelId } = req.params;
|
||||
const userId = req.userId;
|
||||
|
||||
if (!travelId) {
|
||||
throw new AppError('旅行活动ID不能为空', 400);
|
||||
}
|
||||
|
||||
const stats = await TravelRegistrationService.getRegistrationStats(parseInt(travelId), userId);
|
||||
|
||||
res.json(success({ stats }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TravelRegistrationController;
|
||||
261
backend/src/middleware/errorHandler.js
Normal file
261
backend/src/middleware/errorHandler.js
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 统一错误处理中间件
|
||||
* 处理应用程序中的所有错误,提供统一的错误响应格式
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 自定义错误类
|
||||
*/
|
||||
class AppError extends Error {
|
||||
constructor(message, statusCode, errorCode = null) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.errorCode = errorCode;
|
||||
this.isOperational = true;
|
||||
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步错误捕获包装器
|
||||
* @param {Function} fn - 异步函数
|
||||
* @returns {Function} 包装后的函数
|
||||
*/
|
||||
const catchAsync = (fn) => {
|
||||
return (req, res, next) => {
|
||||
fn(req, res, next).catch(next);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理数据库错误
|
||||
* @param {Error} err - 数据库错误
|
||||
* @returns {AppError} 应用错误
|
||||
*/
|
||||
const handleDatabaseError = (err) => {
|
||||
if (err.code === 'ER_DUP_ENTRY') {
|
||||
return new AppError('数据已存在,请检查输入信息', 400, 'DUPLICATE_ENTRY');
|
||||
}
|
||||
|
||||
if (err.code === 'ER_NO_REFERENCED_ROW_2') {
|
||||
return new AppError('关联数据不存在', 400, 'FOREIGN_KEY_CONSTRAINT');
|
||||
}
|
||||
|
||||
if (err.code === 'ER_ROW_IS_REFERENCED_2') {
|
||||
return new AppError('数据正在被使用,无法删除', 400, 'REFERENCED_DATA');
|
||||
}
|
||||
|
||||
if (err.code === 'ER_DATA_TOO_LONG') {
|
||||
return new AppError('输入数据过长', 400, 'DATA_TOO_LONG');
|
||||
}
|
||||
|
||||
if (err.code === 'ER_BAD_NULL_ERROR') {
|
||||
return new AppError('必填字段不能为空', 400, 'REQUIRED_FIELD_MISSING');
|
||||
}
|
||||
|
||||
return new AppError('数据库操作失败', 500, 'DATABASE_ERROR');
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理JWT错误
|
||||
* @param {Error} err - JWT错误
|
||||
* @returns {AppError} 应用错误
|
||||
*/
|
||||
const handleJWTError = (err) => {
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
return new AppError('无效的访问令牌', 401, 'INVALID_TOKEN');
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return new AppError('访问令牌已过期', 401, 'TOKEN_EXPIRED');
|
||||
}
|
||||
|
||||
return new AppError('令牌验证失败', 401, 'TOKEN_VERIFICATION_FAILED');
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理验证错误
|
||||
* @param {Error} err - 验证错误
|
||||
* @returns {AppError} 应用错误
|
||||
*/
|
||||
const handleValidationError = (err) => {
|
||||
if (err.name === 'ValidationError') {
|
||||
const errors = Object.values(err.errors).map(e => e.message);
|
||||
return new AppError(`数据验证失败: ${errors.join(', ')}`, 400, 'VALIDATION_ERROR');
|
||||
}
|
||||
|
||||
return new AppError('数据格式错误', 400, 'INVALID_DATA_FORMAT');
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理文件上传错误
|
||||
* @param {Error} err - 文件上传错误
|
||||
* @returns {AppError} 应用错误
|
||||
*/
|
||||
const handleFileUploadError = (err) => {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return new AppError('文件大小超出限制', 400, 'FILE_TOO_LARGE');
|
||||
}
|
||||
|
||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||
return new AppError('文件数量超出限制', 400, 'TOO_MANY_FILES');
|
||||
}
|
||||
|
||||
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
return new AppError('不支持的文件类型', 400, 'UNSUPPORTED_FILE_TYPE');
|
||||
}
|
||||
|
||||
return new AppError('文件上传失败', 400, 'FILE_UPLOAD_ERROR');
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送错误响应
|
||||
* @param {Error} err - 错误对象
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
*/
|
||||
const sendErrorResponse = (err, req, res) => {
|
||||
const { statusCode, message, errorCode } = err;
|
||||
|
||||
// 构建错误响应
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
message: message || '服务器内部错误',
|
||||
error_code: errorCode || 'INTERNAL_ERROR',
|
||||
timestamp: new Date().toISOString(),
|
||||
path: req.originalUrl,
|
||||
method: req.method
|
||||
};
|
||||
|
||||
// 开发环境下包含错误堆栈
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
errorResponse.stack = err.stack;
|
||||
errorResponse.details = err;
|
||||
}
|
||||
|
||||
// 记录错误日志
|
||||
logger.error('API Error:', {
|
||||
message: err.message,
|
||||
statusCode,
|
||||
errorCode,
|
||||
path: req.originalUrl,
|
||||
method: req.method,
|
||||
userAgent: req.get('User-Agent'),
|
||||
ip: req.ip,
|
||||
userId: req.user?.id,
|
||||
stack: err.stack
|
||||
});
|
||||
|
||||
res.status(statusCode).json(errorResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局错误处理中间件
|
||||
* @param {Error} err - 错误对象
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
const globalErrorHandler = (err, req, res, next) => {
|
||||
// 设置默认错误状态码
|
||||
err.statusCode = err.statusCode || 500;
|
||||
|
||||
let error = { ...err };
|
||||
error.message = err.message;
|
||||
|
||||
// 处理不同类型的错误
|
||||
if (err.code && err.code.startsWith('ER_')) {
|
||||
error = handleDatabaseError(err);
|
||||
} else if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
||||
error = handleJWTError(err);
|
||||
} else if (err.name === 'ValidationError') {
|
||||
error = handleValidationError(err);
|
||||
} else if (err.code && err.code.startsWith('LIMIT_')) {
|
||||
error = handleFileUploadError(err);
|
||||
} else if (err.name === 'CastError') {
|
||||
error = new AppError('无效的数据格式', 400, 'INVALID_DATA_FORMAT');
|
||||
} else if (err.code === 'ENOENT') {
|
||||
error = new AppError('文件不存在', 404, 'FILE_NOT_FOUND');
|
||||
} else if (err.code === 'EACCES') {
|
||||
error = new AppError('文件访问权限不足', 403, 'FILE_ACCESS_DENIED');
|
||||
}
|
||||
|
||||
// 如果不是操作性错误,设置为服务器错误
|
||||
if (!error.isOperational) {
|
||||
error.statusCode = 500;
|
||||
error.message = '服务器内部错误';
|
||||
error.errorCode = 'INTERNAL_ERROR';
|
||||
}
|
||||
|
||||
sendErrorResponse(error, req, res);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理未找到的路由
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
const notFoundHandler = (req, res, next) => {
|
||||
const err = new AppError(`路由 ${req.originalUrl} 不存在`, 404, 'ROUTE_NOT_FOUND');
|
||||
next(err);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理未捕获的Promise拒绝
|
||||
*/
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
// 优雅关闭服务器
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理未捕获的异常
|
||||
*/
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.error('Uncaught Exception:', err);
|
||||
// 优雅关闭服务器
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* 常用错误类型
|
||||
*/
|
||||
const ErrorTypes = {
|
||||
// 认证相关
|
||||
UNAUTHORIZED: (message = '未授权访问') => new AppError(message, 401, 'UNAUTHORIZED'),
|
||||
FORBIDDEN: (message = '权限不足') => new AppError(message, 403, 'FORBIDDEN'),
|
||||
TOKEN_EXPIRED: (message = '访问令牌已过期') => new AppError(message, 401, 'TOKEN_EXPIRED'),
|
||||
|
||||
// 数据相关
|
||||
NOT_FOUND: (message = '资源不存在') => new AppError(message, 404, 'NOT_FOUND'),
|
||||
DUPLICATE_ENTRY: (message = '数据已存在') => new AppError(message, 400, 'DUPLICATE_ENTRY'),
|
||||
VALIDATION_ERROR: (message = '数据验证失败') => new AppError(message, 400, 'VALIDATION_ERROR'),
|
||||
|
||||
// 业务相关
|
||||
BUSINESS_ERROR: (message = '业务处理失败') => new AppError(message, 400, 'BUSINESS_ERROR'),
|
||||
INSUFFICIENT_BALANCE: (message = '余额不足') => new AppError(message, 400, 'INSUFFICIENT_BALANCE'),
|
||||
OPERATION_NOT_ALLOWED: (message = '操作不被允许') => new AppError(message, 400, 'OPERATION_NOT_ALLOWED'),
|
||||
|
||||
// 系统相关
|
||||
INTERNAL_ERROR: (message = '服务器内部错误') => new AppError(message, 500, 'INTERNAL_ERROR'),
|
||||
SERVICE_UNAVAILABLE: (message = '服务暂不可用') => new AppError(message, 503, 'SERVICE_UNAVAILABLE'),
|
||||
RATE_LIMIT_EXCEEDED: (message = '请求频率超出限制') => new AppError(message, 429, 'RATE_LIMIT_EXCEEDED'),
|
||||
|
||||
// 文件相关
|
||||
FILE_TOO_LARGE: (message = '文件大小超出限制') => new AppError(message, 400, 'FILE_TOO_LARGE'),
|
||||
UNSUPPORTED_FILE_TYPE: (message = '不支持的文件类型') => new AppError(message, 400, 'UNSUPPORTED_FILE_TYPE'),
|
||||
FILE_UPLOAD_ERROR: (message = '文件上传失败') => new AppError(message, 400, 'FILE_UPLOAD_ERROR')
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
AppError,
|
||||
catchAsync,
|
||||
globalErrorHandler,
|
||||
notFoundHandler,
|
||||
ErrorTypes
|
||||
};
|
||||
488
backend/src/middleware/upload.js
Normal file
488
backend/src/middleware/upload.js
Normal file
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* 文件上传中间件
|
||||
* 支持图片上传、文件类型验证、大小限制等功能
|
||||
*/
|
||||
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
const { AppError, ErrorTypes } = require('./errorHandler');
|
||||
const { logSystemEvent, logError } = require('../utils/logger');
|
||||
|
||||
// 确保上传目录存在
|
||||
const uploadDir = path.join(__dirname, '../../uploads');
|
||||
const avatarDir = path.join(uploadDir, 'avatars');
|
||||
const animalDir = path.join(uploadDir, 'animals');
|
||||
const travelDir = path.join(uploadDir, 'travels');
|
||||
const documentDir = path.join(uploadDir, 'documents');
|
||||
|
||||
[uploadDir, avatarDir, animalDir, travelDir, documentDir].forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 生成唯一文件名
|
||||
* @param {string} originalName - 原始文件名
|
||||
* @returns {string} 唯一文件名
|
||||
*/
|
||||
const generateUniqueFileName = (originalName) => {
|
||||
const timestamp = Date.now();
|
||||
const randomString = crypto.randomBytes(8).toString('hex');
|
||||
const ext = path.extname(originalName).toLowerCase();
|
||||
return `${timestamp}_${randomString}${ext}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取文件存储目录
|
||||
* @param {string} type - 文件类型
|
||||
* @returns {string} 存储目录路径
|
||||
*/
|
||||
const getStorageDir = (type) => {
|
||||
switch (type) {
|
||||
case 'avatar':
|
||||
return avatarDir;
|
||||
case 'animal':
|
||||
return animalDir;
|
||||
case 'travel':
|
||||
return travelDir;
|
||||
case 'document':
|
||||
return documentDir;
|
||||
default:
|
||||
return uploadDir;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 文件过滤器
|
||||
* @param {string} type - 文件类型
|
||||
* @returns {Function} 过滤器函数
|
||||
*/
|
||||
const createFileFilter = (type) => {
|
||||
return (req, file, cb) => {
|
||||
try {
|
||||
let allowedTypes = [];
|
||||
let allowedMimes = [];
|
||||
|
||||
switch (type) {
|
||||
case 'image':
|
||||
allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
break;
|
||||
case 'document':
|
||||
allowedTypes = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt'];
|
||||
allowedMimes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain'
|
||||
];
|
||||
break;
|
||||
case 'avatar':
|
||||
allowedTypes = ['.jpg', '.jpeg', '.png'];
|
||||
allowedMimes = ['image/jpeg', 'image/png'];
|
||||
break;
|
||||
default:
|
||||
allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.doc', '.docx'];
|
||||
allowedMimes = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf', 'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
];
|
||||
}
|
||||
|
||||
const fileExt = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
if (allowedTypes.includes(fileExt) && allowedMimes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new AppError(`不支持的文件类型。允许的类型: ${allowedTypes.join(', ')}`, 400, 'UNSUPPORTED_FILE_TYPE'));
|
||||
}
|
||||
} catch (error) {
|
||||
cb(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建存储配置
|
||||
* @param {string} type - 文件类型
|
||||
* @returns {Object} 存储配置
|
||||
*/
|
||||
const createStorage = (type) => {
|
||||
return multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const dir = getStorageDir(type);
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueName = generateUniqueFileName(file.originalname);
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建上传中间件
|
||||
* @param {Object} options - 配置选项
|
||||
* @returns {Function} 上传中间件
|
||||
*/
|
||||
const createUploadMiddleware = (options = {}) => {
|
||||
const {
|
||||
type = 'image',
|
||||
maxSize = 5 * 1024 * 1024, // 5MB
|
||||
maxFiles = 1,
|
||||
fieldName = 'file'
|
||||
} = options;
|
||||
|
||||
const upload = multer({
|
||||
storage: createStorage(type),
|
||||
fileFilter: createFileFilter(type),
|
||||
limits: {
|
||||
fileSize: maxSize,
|
||||
files: maxFiles
|
||||
}
|
||||
});
|
||||
|
||||
return (req, res, next) => {
|
||||
const uploadHandler = maxFiles === 1 ? upload.single(fieldName) : upload.array(fieldName, maxFiles);
|
||||
|
||||
uploadHandler(req, res, (err) => {
|
||||
if (err) {
|
||||
logError(err, {
|
||||
type: 'file_upload_error',
|
||||
userId: req.user?.id,
|
||||
fieldName,
|
||||
maxSize,
|
||||
maxFiles
|
||||
});
|
||||
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return next(ErrorTypes.FILE_TOO_LARGE(`文件大小不能超过 ${Math.round(maxSize / 1024 / 1024)}MB`));
|
||||
} else if (err.code === 'LIMIT_FILE_COUNT') {
|
||||
return next(ErrorTypes.FILE_UPLOAD_ERROR(`文件数量不能超过 ${maxFiles} 个`));
|
||||
} else if (err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
return next(ErrorTypes.UNSUPPORTED_FILE_TYPE('不支持的文件字段'));
|
||||
}
|
||||
}
|
||||
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// 记录上传成功日志
|
||||
if (req.file || req.files) {
|
||||
const files = req.files || [req.file];
|
||||
logSystemEvent('file_uploaded', {
|
||||
userId: req.user?.id,
|
||||
fileCount: files.length,
|
||||
files: files.map(f => ({
|
||||
originalName: f.originalname,
|
||||
filename: f.filename,
|
||||
size: f.size,
|
||||
mimetype: f.mimetype
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 图片处理中间件
|
||||
* @param {Object} options - 处理选项
|
||||
* @returns {Function} 处理中间件
|
||||
*/
|
||||
const processImage = (options = {}) => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file && !req.files) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const files = req.files || [req.file];
|
||||
const processedFiles = [];
|
||||
|
||||
for (const file of files) {
|
||||
// 只处理图片文件
|
||||
if (!file.mimetype.startsWith('image/')) {
|
||||
processedFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
const {
|
||||
width = null,
|
||||
height = null,
|
||||
quality = 80,
|
||||
format = 'jpeg',
|
||||
thumbnail = false,
|
||||
thumbnailSize = 200
|
||||
} = options;
|
||||
|
||||
const inputPath = file.path;
|
||||
const outputPath = inputPath.replace(path.extname(inputPath), `.${format}`);
|
||||
|
||||
let sharpInstance = sharp(inputPath);
|
||||
|
||||
// 调整尺寸
|
||||
if (width || height) {
|
||||
sharpInstance = sharpInstance.resize(width, height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
});
|
||||
}
|
||||
|
||||
// 设置质量和格式
|
||||
if (format === 'jpeg') {
|
||||
sharpInstance = sharpInstance.jpeg({ quality });
|
||||
} else if (format === 'png') {
|
||||
sharpInstance = sharpInstance.png({ quality });
|
||||
} else if (format === 'webp') {
|
||||
sharpInstance = sharpInstance.webp({ quality });
|
||||
}
|
||||
|
||||
// 保存处理后的图片
|
||||
await sharpInstance.toFile(outputPath);
|
||||
|
||||
// 删除原始文件(如果格式不同)
|
||||
if (inputPath !== outputPath) {
|
||||
fs.unlinkSync(inputPath);
|
||||
}
|
||||
|
||||
// 更新文件信息
|
||||
file.path = outputPath;
|
||||
file.filename = path.basename(outputPath);
|
||||
|
||||
// 生成缩略图
|
||||
if (thumbnail) {
|
||||
const thumbnailPath = outputPath.replace(
|
||||
path.extname(outputPath),
|
||||
`_thumb${path.extname(outputPath)}`
|
||||
);
|
||||
|
||||
await sharp(outputPath)
|
||||
.resize(thumbnailSize, thumbnailSize, {
|
||||
fit: 'cover',
|
||||
position: 'center'
|
||||
})
|
||||
.jpeg({ quality: 70 })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
file.thumbnail = path.basename(thumbnailPath);
|
||||
}
|
||||
|
||||
processedFiles.push(file);
|
||||
}
|
||||
|
||||
// 更新请求对象
|
||||
if (req.files) {
|
||||
req.files = processedFiles;
|
||||
} else {
|
||||
req.file = processedFiles[0];
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logError(error, {
|
||||
type: 'image_processing_error',
|
||||
userId: req.user?.id,
|
||||
options
|
||||
});
|
||||
next(ErrorTypes.FILE_UPLOAD_ERROR('图片处理失败'));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Promise<boolean>} 删除结果
|
||||
*/
|
||||
const deleteFile = async (filePath) => {
|
||||
try {
|
||||
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(uploadDir, filePath);
|
||||
|
||||
if (fs.existsSync(fullPath)) {
|
||||
fs.unlinkSync(fullPath);
|
||||
|
||||
// 同时删除缩略图
|
||||
const thumbnailPath = fullPath.replace(
|
||||
path.extname(fullPath),
|
||||
`_thumb${path.extname(fullPath)}`
|
||||
);
|
||||
if (fs.existsSync(thumbnailPath)) {
|
||||
fs.unlinkSync(thumbnailPath);
|
||||
}
|
||||
|
||||
logSystemEvent('file_deleted', { filePath: fullPath });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logError(error, { type: 'file_deletion_error', filePath });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Object|null} 文件信息
|
||||
*/
|
||||
const getFileInfo = (filePath) => {
|
||||
try {
|
||||
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(uploadDir, filePath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(fullPath);
|
||||
const ext = path.extname(fullPath).toLowerCase();
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime,
|
||||
extension: ext,
|
||||
isImage: ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, { type: 'file_info_error', filePath });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理临时文件
|
||||
* @param {number} maxAge - 最大存在时间(毫秒)
|
||||
*/
|
||||
const cleanupTempFiles = (maxAge = 24 * 60 * 60 * 1000) => {
|
||||
const tempDir = path.join(uploadDir, 'temp');
|
||||
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.readdir(tempDir, (err, files) => {
|
||||
if (err) {
|
||||
logError(err, { type: 'temp_cleanup_error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(tempDir, file);
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
if (err) return;
|
||||
|
||||
if (now - stats.mtime.getTime() > maxAge) {
|
||||
fs.unlink(filePath, (err) => {
|
||||
if (err) {
|
||||
logError(err, { type: 'temp_file_deletion_error', filePath });
|
||||
} else {
|
||||
logSystemEvent('temp_file_cleaned', { filePath });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 每小时清理一次临时文件
|
||||
setInterval(cleanupTempFiles, 60 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* 预定义的上传中间件
|
||||
*/
|
||||
const uploadMiddlewares = {
|
||||
// 头像上传
|
||||
avatar: createUploadMiddleware({
|
||||
type: 'avatar',
|
||||
maxSize: 2 * 1024 * 1024, // 2MB
|
||||
maxFiles: 1,
|
||||
fieldName: 'avatar'
|
||||
}),
|
||||
|
||||
// 动物图片上传
|
||||
animalImages: createUploadMiddleware({
|
||||
type: 'animal',
|
||||
maxSize: 5 * 1024 * 1024, // 5MB
|
||||
maxFiles: 5,
|
||||
fieldName: 'images'
|
||||
}),
|
||||
|
||||
// 旅行图片上传
|
||||
travelImages: createUploadMiddleware({
|
||||
type: 'travel',
|
||||
maxSize: 5 * 1024 * 1024, // 5MB
|
||||
maxFiles: 10,
|
||||
fieldName: 'images'
|
||||
}),
|
||||
|
||||
// 文档上传
|
||||
documents: createUploadMiddleware({
|
||||
type: 'document',
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: 3,
|
||||
fieldName: 'documents'
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* 预定义的图片处理中间件
|
||||
*/
|
||||
const imageProcessors = {
|
||||
// 头像处理
|
||||
avatar: processImage({
|
||||
width: 300,
|
||||
height: 300,
|
||||
quality: 85,
|
||||
format: 'jpeg',
|
||||
thumbnail: true,
|
||||
thumbnailSize: 100
|
||||
}),
|
||||
|
||||
// 动物图片处理
|
||||
animal: processImage({
|
||||
width: 800,
|
||||
height: 600,
|
||||
quality: 80,
|
||||
format: 'jpeg',
|
||||
thumbnail: true,
|
||||
thumbnailSize: 200
|
||||
}),
|
||||
|
||||
// 旅行图片处理
|
||||
travel: processImage({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
quality: 80,
|
||||
format: 'jpeg',
|
||||
thumbnail: true,
|
||||
thumbnailSize: 300
|
||||
})
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createUploadMiddleware,
|
||||
processImage,
|
||||
deleteFile,
|
||||
getFileInfo,
|
||||
cleanupTempFiles,
|
||||
uploadMiddlewares,
|
||||
imageProcessors,
|
||||
generateUniqueFileName,
|
||||
getStorageDir
|
||||
};
|
||||
582
backend/src/models/AnimalClaim.js
Normal file
582
backend/src/models/AnimalClaim.js
Normal file
@@ -0,0 +1,582 @@
|
||||
const db = require('../config/database');
|
||||
|
||||
class AnimalClaim {
|
||||
/**
|
||||
* 创建认领申请
|
||||
* @param {Object} claimData - 认领申请数据
|
||||
* @returns {Object} 创建的认领申请
|
||||
*/
|
||||
static async create(claimData) {
|
||||
try {
|
||||
const {
|
||||
claim_no,
|
||||
animal_id,
|
||||
user_id,
|
||||
claim_reason,
|
||||
claim_duration,
|
||||
total_amount,
|
||||
contact_info,
|
||||
status = 'pending'
|
||||
} = claimData;
|
||||
|
||||
const query = `
|
||||
INSERT INTO animal_claims (
|
||||
claim_no, animal_id, user_id, claim_reason, claim_duration,
|
||||
total_amount, contact_info, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
`;
|
||||
|
||||
const [result] = await db.execute(query, [
|
||||
claim_no,
|
||||
animal_id,
|
||||
user_id,
|
||||
claim_reason,
|
||||
claim_duration,
|
||||
total_amount,
|
||||
contact_info,
|
||||
status
|
||||
]);
|
||||
|
||||
return await this.findById(result.insertId);
|
||||
} catch (error) {
|
||||
console.error('创建认领申请数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查找认领申请
|
||||
* @param {number} id - 认领申请ID
|
||||
* @returns {Object|null} 认领申请信息
|
||||
*/
|
||||
static async findById(id) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
ac.*,
|
||||
a.name as animal_name,
|
||||
a.type as animal_type,
|
||||
a.image as animal_image,
|
||||
a.price as animal_price,
|
||||
u.username,
|
||||
u.phone as user_phone,
|
||||
reviewer.username as reviewer_name
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN animals a ON ac.animal_id = a.id
|
||||
LEFT JOIN users u ON ac.user_id = u.id
|
||||
LEFT JOIN users reviewer ON ac.reviewed_by = reviewer.id
|
||||
WHERE ac.id = ? AND ac.deleted_at IS NULL
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [id]);
|
||||
return rows[0] || null;
|
||||
} catch (error) {
|
||||
console.error('查找认领申请数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据认领订单号查找
|
||||
* @param {string} claimNo - 认领订单号
|
||||
* @returns {Object|null} 认领申请信息
|
||||
*/
|
||||
static async findByClaimNo(claimNo) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
ac.*,
|
||||
a.name as animal_name,
|
||||
a.type as animal_type,
|
||||
a.image as animal_image,
|
||||
u.username,
|
||||
u.phone as user_phone
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN animals a ON ac.animal_id = a.id
|
||||
LEFT JOIN users u ON ac.user_id = u.id
|
||||
WHERE ac.claim_no = ? AND ac.deleted_at IS NULL
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [claimNo]);
|
||||
return rows[0] || null;
|
||||
} catch (error) {
|
||||
console.error('根据订单号查找认领申请数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找用户对特定动物的活跃认领申请
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} animalId - 动物ID
|
||||
* @returns {Object|null} 认领申请信息
|
||||
*/
|
||||
static async findActiveClaimByUserAndAnimal(userId, animalId) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT * FROM animal_claims
|
||||
WHERE user_id = ? AND animal_id = ?
|
||||
AND status IN ('pending', 'approved')
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [userId, animalId]);
|
||||
return rows[0] || null;
|
||||
} catch (error) {
|
||||
console.error('查找活跃认领申请数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新认领申请状态
|
||||
* @param {number} id - 认领申请ID
|
||||
* @param {string} status - 新状态
|
||||
* @param {Object} updateData - 更新数据
|
||||
* @returns {Object} 更新后的认领申请
|
||||
*/
|
||||
static async updateStatus(id, status, updateData = {}) {
|
||||
try {
|
||||
const fields = ['status = ?', 'updated_at = NOW()'];
|
||||
const values = [status];
|
||||
|
||||
// 动态添加更新字段
|
||||
Object.keys(updateData).forEach(key => {
|
||||
if (updateData[key] !== undefined) {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(updateData[key]);
|
||||
}
|
||||
});
|
||||
|
||||
values.push(id);
|
||||
|
||||
const query = `
|
||||
UPDATE animal_claims
|
||||
SET ${fields.join(', ')}
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await db.execute(query, values);
|
||||
return await this.findById(id);
|
||||
} catch (error) {
|
||||
console.error('更新认领申请状态数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的认领申请列表
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
static async getUserClaims(userId, options = {}) {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status,
|
||||
animal_type,
|
||||
start_date,
|
||||
end_date
|
||||
} = options;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
let whereConditions = ['ac.user_id = ?', 'ac.deleted_at IS NULL'];
|
||||
let queryParams = [userId];
|
||||
|
||||
// 添加筛选条件
|
||||
if (status) {
|
||||
whereConditions.push('ac.status = ?');
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
if (animal_type) {
|
||||
whereConditions.push('a.type = ?');
|
||||
queryParams.push(animal_type);
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
whereConditions.push('ac.created_at >= ?');
|
||||
queryParams.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
whereConditions.push('ac.created_at <= ?');
|
||||
queryParams.push(end_date);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
// 查询数据
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
ac.*,
|
||||
a.name as animal_name,
|
||||
a.type as animal_type,
|
||||
a.image as animal_image,
|
||||
a.price as animal_price
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN animals a ON ac.animal_id = a.id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY ac.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
const [dataRows] = await db.execute(dataQuery, [...queryParams, limit, offset]);
|
||||
|
||||
// 查询总数
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN animals a ON ac.animal_id = a.id
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const [countRows] = await db.execute(countQuery, queryParams);
|
||||
const total = countRows[0].total;
|
||||
|
||||
return {
|
||||
data: dataRows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取用户认领申请列表数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动物的认领申请列表
|
||||
* @param {number} animalId - 动物ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
static async getAnimalClaims(animalId, options = {}) {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status
|
||||
} = options;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
let whereConditions = ['ac.animal_id = ?', 'ac.deleted_at IS NULL'];
|
||||
let queryParams = [animalId];
|
||||
|
||||
if (status) {
|
||||
whereConditions.push('ac.status = ?');
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
// 查询数据
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
ac.*,
|
||||
u.username,
|
||||
u.phone as user_phone,
|
||||
u.email as user_email
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN users u ON ac.user_id = u.id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY ac.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
const [dataRows] = await db.execute(dataQuery, [...queryParams, limit, offset]);
|
||||
|
||||
// 查询总数
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM animal_claims ac
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const [countRows] = await db.execute(countQuery, queryParams);
|
||||
const total = countRows[0].total;
|
||||
|
||||
return {
|
||||
data: dataRows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取动物认领申请列表数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有认领申请列表(管理员)
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
static async getAllClaims(options = {}) {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status,
|
||||
animal_type,
|
||||
user_id,
|
||||
start_date,
|
||||
end_date,
|
||||
keyword
|
||||
} = options;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
let whereConditions = ['ac.deleted_at IS NULL'];
|
||||
let queryParams = [];
|
||||
|
||||
// 添加筛选条件
|
||||
if (status) {
|
||||
whereConditions.push('ac.status = ?');
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
if (animal_type) {
|
||||
whereConditions.push('a.type = ?');
|
||||
queryParams.push(animal_type);
|
||||
}
|
||||
|
||||
if (user_id) {
|
||||
whereConditions.push('ac.user_id = ?');
|
||||
queryParams.push(user_id);
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
whereConditions.push('ac.created_at >= ?');
|
||||
queryParams.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
whereConditions.push('ac.created_at <= ?');
|
||||
queryParams.push(end_date);
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
whereConditions.push('(ac.claim_no LIKE ? OR a.name LIKE ? OR u.username LIKE ?)');
|
||||
const keywordPattern = `%${keyword}%`;
|
||||
queryParams.push(keywordPattern, keywordPattern, keywordPattern);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
// 查询数据
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
ac.*,
|
||||
a.name as animal_name,
|
||||
a.type as animal_type,
|
||||
a.image as animal_image,
|
||||
a.price as animal_price,
|
||||
u.username,
|
||||
u.phone as user_phone,
|
||||
u.email as user_email,
|
||||
reviewer.username as reviewer_name
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN animals a ON ac.animal_id = a.id
|
||||
LEFT JOIN users u ON ac.user_id = u.id
|
||||
LEFT JOIN users reviewer ON ac.reviewed_by = reviewer.id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY ac.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
const [dataRows] = await db.execute(dataQuery, [...queryParams, limit, offset]);
|
||||
|
||||
// 查询总数
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN animals a ON ac.animal_id = a.id
|
||||
LEFT JOIN users u ON ac.user_id = u.id
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const [countRows] = await db.execute(countQuery, queryParams);
|
||||
const total = countRows[0].total;
|
||||
|
||||
return {
|
||||
data: dataRows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取所有认领申请列表数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建续期记录
|
||||
* @param {Object} renewalData - 续期数据
|
||||
* @returns {Object} 续期记录
|
||||
*/
|
||||
static async createRenewal(renewalData) {
|
||||
try {
|
||||
const {
|
||||
claim_id,
|
||||
duration,
|
||||
amount,
|
||||
payment_method,
|
||||
status = 'pending'
|
||||
} = renewalData;
|
||||
|
||||
const query = `
|
||||
INSERT INTO animal_claim_renewals (
|
||||
claim_id, duration, amount, payment_method, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, NOW(), NOW())
|
||||
`;
|
||||
|
||||
const [result] = await db.execute(query, [
|
||||
claim_id,
|
||||
duration,
|
||||
amount,
|
||||
payment_method,
|
||||
status
|
||||
]);
|
||||
|
||||
return {
|
||||
id: result.insertId,
|
||||
claim_id,
|
||||
duration,
|
||||
amount,
|
||||
payment_method,
|
||||
status
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('创建续期记录数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认领统计信息
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @returns {Object} 统计信息
|
||||
*/
|
||||
static async getClaimStatistics(filters = {}) {
|
||||
try {
|
||||
const { start_date, end_date, animal_type } = filters;
|
||||
let whereConditions = ['ac.deleted_at IS NULL'];
|
||||
let queryParams = [];
|
||||
|
||||
if (start_date) {
|
||||
whereConditions.push('ac.created_at >= ?');
|
||||
queryParams.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
whereConditions.push('ac.created_at <= ?');
|
||||
queryParams.push(end_date);
|
||||
}
|
||||
|
||||
if (animal_type) {
|
||||
whereConditions.push('a.type = ?');
|
||||
queryParams.push(animal_type);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
// 基础统计
|
||||
const basicStatsQuery = `
|
||||
SELECT
|
||||
COUNT(*) as total_claims,
|
||||
COUNT(CASE WHEN ac.status = 'pending' THEN 1 END) as pending_claims,
|
||||
COUNT(CASE WHEN ac.status = 'approved' THEN 1 END) as approved_claims,
|
||||
COUNT(CASE WHEN ac.status = 'rejected' THEN 1 END) as rejected_claims,
|
||||
COUNT(CASE WHEN ac.status = 'cancelled' THEN 1 END) as cancelled_claims,
|
||||
SUM(CASE WHEN ac.status = 'approved' THEN ac.total_amount ELSE 0 END) as total_amount,
|
||||
AVG(CASE WHEN ac.status = 'approved' THEN ac.claim_duration ELSE NULL END) as avg_duration
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN animals a ON ac.animal_id = a.id
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const [basicStats] = await db.execute(basicStatsQuery, queryParams);
|
||||
|
||||
// 按动物类型统计
|
||||
const typeStatsQuery = `
|
||||
SELECT
|
||||
a.type,
|
||||
COUNT(*) as claim_count,
|
||||
COUNT(CASE WHEN ac.status = 'approved' THEN 1 END) as approved_count,
|
||||
SUM(CASE WHEN ac.status = 'approved' THEN ac.total_amount ELSE 0 END) as total_amount
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN animals a ON ac.animal_id = a.id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY a.type
|
||||
ORDER BY claim_count DESC
|
||||
`;
|
||||
|
||||
const [typeStats] = await db.execute(typeStatsQuery, queryParams);
|
||||
|
||||
// 按月份统计
|
||||
const monthlyStatsQuery = `
|
||||
SELECT
|
||||
DATE_FORMAT(ac.created_at, '%Y-%m') as month,
|
||||
COUNT(*) as claim_count,
|
||||
COUNT(CASE WHEN ac.status = 'approved' THEN 1 END) as approved_count,
|
||||
SUM(CASE WHEN ac.status = 'approved' THEN ac.total_amount ELSE 0 END) as total_amount
|
||||
FROM animal_claims ac
|
||||
LEFT JOIN animals a ON ac.animal_id = a.id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY DATE_FORMAT(ac.created_at, '%Y-%m')
|
||||
ORDER BY month DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
const [monthlyStats] = await db.execute(monthlyStatsQuery, queryParams);
|
||||
|
||||
return {
|
||||
basic: basicStats[0],
|
||||
by_type: typeStats,
|
||||
by_month: monthlyStats
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取认领统计信息数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除认领申请
|
||||
* @param {number} id - 认领申请ID
|
||||
* @returns {boolean} 删除结果
|
||||
*/
|
||||
static async softDelete(id) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE animal_claims
|
||||
SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
const [result] = await db.execute(query, [id]);
|
||||
return result.affectedRows > 0;
|
||||
} catch (error) {
|
||||
console.error('软删除认领申请数据库错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AnimalClaim;
|
||||
499
backend/src/models/Payment.js
Normal file
499
backend/src/models/Payment.js
Normal file
@@ -0,0 +1,499 @@
|
||||
const db = require('../config/database');
|
||||
|
||||
class Payment {
|
||||
/**
|
||||
* 创建支付订单
|
||||
* @param {Object} paymentData - 支付订单数据
|
||||
* @returns {Object} 创建的支付订单
|
||||
*/
|
||||
static async create(paymentData) {
|
||||
const {
|
||||
payment_no,
|
||||
order_id,
|
||||
user_id,
|
||||
amount,
|
||||
payment_method,
|
||||
return_url,
|
||||
notify_url
|
||||
} = paymentData;
|
||||
|
||||
const query = `
|
||||
INSERT INTO payments (
|
||||
payment_no, order_id, user_id, amount, payment_method,
|
||||
return_url, notify_url, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', NOW(), NOW())
|
||||
`;
|
||||
|
||||
const [result] = await db.execute(query, [
|
||||
payment_no, order_id, user_id, amount, payment_method,
|
||||
return_url, notify_url
|
||||
]);
|
||||
|
||||
return this.findById(result.insertId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查找支付订单
|
||||
* @param {number} id - 支付订单ID
|
||||
* @returns {Object|null} 支付订单信息
|
||||
*/
|
||||
static async findById(id) {
|
||||
const query = `
|
||||
SELECT p.*, o.order_no, u.username, u.phone
|
||||
FROM payments p
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
WHERE p.id = ? AND p.deleted_at IS NULL
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付订单号查找支付订单
|
||||
* @param {string} paymentNo - 支付订单号
|
||||
* @returns {Object|null} 支付订单信息
|
||||
*/
|
||||
static async findByPaymentNo(paymentNo) {
|
||||
const query = `
|
||||
SELECT p.*, o.order_no, u.username, u.phone
|
||||
FROM payments p
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
WHERE p.payment_no = ? AND p.deleted_at IS NULL
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [paymentNo]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据订单ID查找支付订单
|
||||
* @param {number} orderId - 订单ID
|
||||
* @returns {Array} 支付订单列表
|
||||
*/
|
||||
static async findByOrderId(orderId) {
|
||||
const query = `
|
||||
SELECT * FROM payments
|
||||
WHERE order_id = ? AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [orderId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付状态
|
||||
* @param {number} id - 支付订单ID
|
||||
* @param {Object} updateData - 更新数据
|
||||
* @returns {Object} 更新后的支付订单
|
||||
*/
|
||||
static async updateStatus(id, updateData) {
|
||||
const {
|
||||
status,
|
||||
transaction_id,
|
||||
paid_amount,
|
||||
paid_at,
|
||||
failure_reason
|
||||
} = updateData;
|
||||
|
||||
const query = `
|
||||
UPDATE payments
|
||||
SET status = ?, transaction_id = ?, paid_amount = ?,
|
||||
paid_at = ?, failure_reason = ?, updated_at = NOW()
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`;
|
||||
|
||||
await db.execute(query, [
|
||||
status, transaction_id, paid_amount,
|
||||
paid_at, failure_reason, id
|
||||
]);
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户支付订单列表
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
static async getUserPayments(userId, options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status,
|
||||
payment_method,
|
||||
start_date,
|
||||
end_date
|
||||
} = options;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
let whereConditions = ['p.user_id = ?', 'p.deleted_at IS NULL'];
|
||||
let params = [userId];
|
||||
|
||||
// 添加筛选条件
|
||||
if (status) {
|
||||
whereConditions.push('p.status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (payment_method) {
|
||||
whereConditions.push('p.payment_method = ?');
|
||||
params.push(payment_method);
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
whereConditions.push('DATE(p.created_at) >= ?');
|
||||
params.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
whereConditions.push('DATE(p.created_at) <= ?');
|
||||
params.push(end_date);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
// 查询总数
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM payments p
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
const [countResult] = await db.execute(countQuery, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 查询数据
|
||||
const dataQuery = `
|
||||
SELECT p.*, o.order_no, o.title as order_title
|
||||
FROM payments p
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
params.push(limit, offset);
|
||||
const [rows] = await db.execute(dataQuery, params);
|
||||
|
||||
return {
|
||||
data: rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支付订单列表(管理员)
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
static async getAllPayments(options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status,
|
||||
payment_method,
|
||||
user_id,
|
||||
start_date,
|
||||
end_date,
|
||||
keyword
|
||||
} = options;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
let whereConditions = ['p.deleted_at IS NULL'];
|
||||
let params = [];
|
||||
|
||||
// 添加筛选条件
|
||||
if (status) {
|
||||
whereConditions.push('p.status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (payment_method) {
|
||||
whereConditions.push('p.payment_method = ?');
|
||||
params.push(payment_method);
|
||||
}
|
||||
|
||||
if (user_id) {
|
||||
whereConditions.push('p.user_id = ?');
|
||||
params.push(user_id);
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
whereConditions.push('DATE(p.created_at) >= ?');
|
||||
params.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
whereConditions.push('DATE(p.created_at) <= ?');
|
||||
params.push(end_date);
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
whereConditions.push('(p.payment_no LIKE ? OR o.order_no LIKE ? OR u.username LIKE ?)');
|
||||
const keywordPattern = `%${keyword}%`;
|
||||
params.push(keywordPattern, keywordPattern, keywordPattern);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
// 查询总数
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM payments p
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
const [countResult] = await db.execute(countQuery, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 查询数据
|
||||
const dataQuery = `
|
||||
SELECT p.*, o.order_no, o.title as order_title,
|
||||
u.username, u.phone
|
||||
FROM payments p
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
params.push(limit, offset);
|
||||
const [rows] = await db.execute(dataQuery, params);
|
||||
|
||||
return {
|
||||
data: rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建退款记录
|
||||
* @param {Object} refundData - 退款数据
|
||||
* @returns {Object} 创建的退款记录
|
||||
*/
|
||||
static async createRefund(refundData) {
|
||||
const {
|
||||
refund_no,
|
||||
payment_id,
|
||||
user_id,
|
||||
refund_amount,
|
||||
refund_reason
|
||||
} = refundData;
|
||||
|
||||
const query = `
|
||||
INSERT INTO refunds (
|
||||
refund_no, payment_id, user_id, refund_amount,
|
||||
refund_reason, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', NOW(), NOW())
|
||||
`;
|
||||
|
||||
const [result] = await db.execute(query, [
|
||||
refund_no, payment_id, user_id, refund_amount, refund_reason
|
||||
]);
|
||||
|
||||
return this.findRefundById(result.insertId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查找退款记录
|
||||
* @param {number} id - 退款ID
|
||||
* @returns {Object|null} 退款记录
|
||||
*/
|
||||
static async findRefundById(id) {
|
||||
const query = `
|
||||
SELECT r.*, p.payment_no, p.amount as payment_amount,
|
||||
u.username, u.phone,
|
||||
admin.username as processed_by_name
|
||||
FROM refunds r
|
||||
LEFT JOIN payments p ON r.payment_id = p.id
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
LEFT JOIN users admin ON r.processed_by = admin.id
|
||||
WHERE r.id = ? AND r.deleted_at IS NULL
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新退款状态
|
||||
* @param {number} id - 退款ID
|
||||
* @param {Object} updateData - 更新数据
|
||||
* @returns {Object} 更新后的退款记录
|
||||
*/
|
||||
static async updateRefundStatus(id, updateData) {
|
||||
const {
|
||||
status,
|
||||
processed_by,
|
||||
process_remark,
|
||||
refund_transaction_id,
|
||||
refunded_at
|
||||
} = updateData;
|
||||
|
||||
const query = `
|
||||
UPDATE refunds
|
||||
SET status = ?, processed_by = ?, process_remark = ?,
|
||||
refund_transaction_id = ?, refunded_at = ?,
|
||||
processed_at = NOW(), updated_at = NOW()
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`;
|
||||
|
||||
await db.execute(query, [
|
||||
status, processed_by, process_remark,
|
||||
refund_transaction_id, refunded_at, id
|
||||
]);
|
||||
|
||||
return this.findRefundById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付统计信息
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @returns {Object} 统计信息
|
||||
*/
|
||||
static async getPaymentStatistics(filters = {}) {
|
||||
const {
|
||||
start_date,
|
||||
end_date,
|
||||
payment_method
|
||||
} = filters;
|
||||
|
||||
let whereConditions = ['deleted_at IS NULL'];
|
||||
let params = [];
|
||||
|
||||
if (start_date) {
|
||||
whereConditions.push('DATE(created_at) >= ?');
|
||||
params.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
whereConditions.push('DATE(created_at) <= ?');
|
||||
params.push(end_date);
|
||||
}
|
||||
|
||||
if (payment_method) {
|
||||
whereConditions.push('payment_method = ?');
|
||||
params.push(payment_method);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
// 总体统计
|
||||
const totalQuery = `
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
COALESCE(SUM(amount), 0) as total_amount,
|
||||
COUNT(CASE WHEN status = 'paid' THEN 1 END) as success_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'paid' THEN paid_amount END), 0) as success_amount
|
||||
FROM payments
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
const [totalResult] = await db.execute(totalQuery, params);
|
||||
|
||||
// 退款统计
|
||||
const refundQuery = `
|
||||
SELECT
|
||||
COUNT(*) as refund_count,
|
||||
COALESCE(SUM(refund_amount), 0) as refund_amount
|
||||
FROM refunds r
|
||||
JOIN payments p ON r.payment_id = p.id
|
||||
WHERE r.status = 'completed' AND r.deleted_at IS NULL
|
||||
${start_date ? 'AND DATE(r.created_at) >= ?' : ''}
|
||||
${end_date ? 'AND DATE(r.created_at) <= ?' : ''}
|
||||
${payment_method ? 'AND p.payment_method = ?' : ''}
|
||||
`;
|
||||
let refundParams = [];
|
||||
if (start_date) refundParams.push(start_date);
|
||||
if (end_date) refundParams.push(end_date);
|
||||
if (payment_method) refundParams.push(payment_method);
|
||||
|
||||
const [refundResult] = await db.execute(refundQuery, refundParams);
|
||||
|
||||
// 按支付方式统计
|
||||
const methodQuery = `
|
||||
SELECT
|
||||
payment_method,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(CASE WHEN status = 'paid' THEN paid_amount END), 0) as amount
|
||||
FROM payments
|
||||
WHERE ${whereClause}
|
||||
GROUP BY payment_method
|
||||
`;
|
||||
const [methodResult] = await db.execute(methodQuery, params);
|
||||
|
||||
return {
|
||||
total_count: totalResult[0].total_count,
|
||||
total_amount: parseFloat(totalResult[0].total_amount),
|
||||
success_count: totalResult[0].success_count,
|
||||
success_amount: parseFloat(totalResult[0].success_amount),
|
||||
refund_count: refundResult[0].refund_count,
|
||||
refund_amount: parseFloat(refundResult[0].refund_amount),
|
||||
method_stats: methodResult.map(row => ({
|
||||
payment_method: row.payment_method,
|
||||
count: row.count,
|
||||
amount: parseFloat(row.amount)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查支付订单是否存在
|
||||
* @param {number} id - 支付订单ID
|
||||
* @returns {boolean} 是否存在
|
||||
*/
|
||||
static async exists(id) {
|
||||
const query = 'SELECT 1 FROM payments WHERE id = ? AND deleted_at IS NULL';
|
||||
const [rows] = await db.execute(query, [id]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除支付订单
|
||||
* @param {number} id - 支付订单ID
|
||||
* @returns {boolean} 删除结果
|
||||
*/
|
||||
static async softDelete(id) {
|
||||
const query = `
|
||||
UPDATE payments
|
||||
SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`;
|
||||
|
||||
const [result] = await db.execute(query, [id]);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据清理 - 删除过期的待支付订单
|
||||
* @param {number} hours - 过期小时数,默认24小时
|
||||
* @returns {number} 清理的记录数
|
||||
*/
|
||||
static async cleanExpiredPayments(hours = 24) {
|
||||
const query = `
|
||||
UPDATE payments
|
||||
SET status = 'cancelled', updated_at = NOW()
|
||||
WHERE status = 'pending'
|
||||
AND created_at < DATE_SUB(NOW(), INTERVAL ? HOUR)
|
||||
AND deleted_at IS NULL
|
||||
`;
|
||||
|
||||
const [result] = await db.execute(query, [hours]);
|
||||
return result.affectedRows;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Payment;
|
||||
319
backend/src/models/TravelRegistration.js
Normal file
319
backend/src/models/TravelRegistration.js
Normal file
@@ -0,0 +1,319 @@
|
||||
const db = require('../config/database');
|
||||
|
||||
/**
|
||||
* 旅行报名数据模型
|
||||
* 处理旅行活动报名相关的数据库操作
|
||||
*/
|
||||
class TravelRegistration {
|
||||
/**
|
||||
* 创建报名记录
|
||||
* @param {Object} registrationData - 报名数据
|
||||
* @returns {Promise<Object>} 创建的报名记录
|
||||
*/
|
||||
static async create(registrationData) {
|
||||
const {
|
||||
travel_plan_id,
|
||||
user_id,
|
||||
message,
|
||||
emergency_contact,
|
||||
emergency_phone
|
||||
} = registrationData;
|
||||
|
||||
const query = `
|
||||
INSERT INTO travel_registrations
|
||||
(travel_plan_id, user_id, message, emergency_contact, emergency_phone, status, applied_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', NOW())
|
||||
`;
|
||||
|
||||
const [result] = await db.execute(query, [
|
||||
travel_plan_id,
|
||||
user_id,
|
||||
message || null,
|
||||
emergency_contact || null,
|
||||
emergency_phone || null
|
||||
]);
|
||||
|
||||
return this.findById(result.insertId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查找报名记录
|
||||
* @param {number} id - 报名记录ID
|
||||
* @returns {Promise<Object|null>} 报名记录
|
||||
*/
|
||||
static async findById(id) {
|
||||
const query = `
|
||||
SELECT
|
||||
tr.*,
|
||||
u.username,
|
||||
u.real_name,
|
||||
u.avatar_url,
|
||||
tp.title as travel_title,
|
||||
tp.destination,
|
||||
tp.start_date,
|
||||
tp.end_date
|
||||
FROM travel_registrations tr
|
||||
LEFT JOIN users u ON tr.user_id = u.id
|
||||
LEFT JOIN travel_plans tp ON tr.travel_plan_id = tp.id
|
||||
WHERE tr.id = ?
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已报名某个旅行活动
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} travelPlanId - 旅行活动ID
|
||||
* @returns {Promise<Object|null>} 报名记录
|
||||
*/
|
||||
static async findByUserAndTravel(userId, travelPlanId) {
|
||||
const query = `
|
||||
SELECT * FROM travel_registrations
|
||||
WHERE user_id = ? AND travel_plan_id = ? AND status != 'cancelled'
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [userId, travelPlanId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的报名记录列表
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Promise<Object>} 报名记录列表和分页信息
|
||||
*/
|
||||
static async findByUser(userId, options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
status
|
||||
} = options;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
let whereClause = 'WHERE tr.user_id = ?';
|
||||
const params = [userId];
|
||||
|
||||
if (status) {
|
||||
whereClause += ' AND tr.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM travel_registrations tr
|
||||
${whereClause}
|
||||
`;
|
||||
const [countResult] = await db.execute(countQuery, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 获取数据
|
||||
const query = `
|
||||
SELECT
|
||||
tr.*,
|
||||
tp.title as travel_title,
|
||||
tp.destination,
|
||||
tp.start_date,
|
||||
tp.end_date,
|
||||
tp.max_participants,
|
||||
tp.current_participants
|
||||
FROM travel_registrations tr
|
||||
LEFT JOIN travel_plans tp ON tr.travel_plan_id = tp.id
|
||||
${whereClause}
|
||||
ORDER BY tr.applied_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
params.push(pageSize, offset);
|
||||
const [rows] = await db.execute(query, params);
|
||||
|
||||
return {
|
||||
registrations: rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旅行活动的报名记录列表
|
||||
* @param {number} travelPlanId - 旅行活动ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Promise<Object>} 报名记录列表和分页信息
|
||||
*/
|
||||
static async findByTravelPlan(travelPlanId, options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
status
|
||||
} = options;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
let whereClause = 'WHERE tr.travel_plan_id = ?';
|
||||
const params = [travelPlanId];
|
||||
|
||||
if (status) {
|
||||
whereClause += ' AND tr.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM travel_registrations tr
|
||||
${whereClause}
|
||||
`;
|
||||
const [countResult] = await db.execute(countQuery, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 获取数据
|
||||
const query = `
|
||||
SELECT
|
||||
tr.*,
|
||||
u.username,
|
||||
u.real_name,
|
||||
u.avatar_url,
|
||||
u.phone,
|
||||
u.email
|
||||
FROM travel_registrations tr
|
||||
LEFT JOIN users u ON tr.user_id = u.id
|
||||
${whereClause}
|
||||
ORDER BY tr.applied_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
params.push(pageSize, offset);
|
||||
const [rows] = await db.execute(query, params);
|
||||
|
||||
return {
|
||||
registrations: rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新报名状态
|
||||
* @param {number} id - 报名记录ID
|
||||
* @param {string} status - 新状态
|
||||
* @param {string} rejectReason - 拒绝原因(可选)
|
||||
* @returns {Promise<Object>} 更新后的报名记录
|
||||
*/
|
||||
static async updateStatus(id, status, rejectReason = null) {
|
||||
const query = `
|
||||
UPDATE travel_registrations
|
||||
SET status = ?, reject_reason = ?, responded_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
await db.execute(query, [status, rejectReason, id]);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消报名
|
||||
* @param {number} id - 报名记录ID
|
||||
* @returns {Promise<Object>} 更新后的报名记录
|
||||
*/
|
||||
static async cancel(id) {
|
||||
return this.updateStatus(id, 'cancelled');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取报名统计信息
|
||||
* @param {number} travelPlanId - 旅行活动ID
|
||||
* @returns {Promise<Object>} 统计信息
|
||||
*/
|
||||
static async getStats(travelPlanId) {
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(*) as total_applications,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
|
||||
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count,
|
||||
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count,
|
||||
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_count
|
||||
FROM travel_registrations
|
||||
WHERE travel_plan_id = ?
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [travelPlanId]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有权限查看旅行活动的报名列表
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} travelPlanId - 旅行活动ID
|
||||
* @returns {Promise<boolean>} 是否有权限
|
||||
*/
|
||||
static async canViewRegistrations(userId, travelPlanId) {
|
||||
const query = `
|
||||
SELECT id FROM travel_plans
|
||||
WHERE id = ? AND created_by = ?
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [travelPlanId, userId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有权限审核报名
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} registrationId - 报名记录ID
|
||||
* @returns {Promise<boolean>} 是否有权限
|
||||
*/
|
||||
static async canReviewRegistration(userId, registrationId) {
|
||||
const query = `
|
||||
SELECT tr.id
|
||||
FROM travel_registrations tr
|
||||
JOIN travel_plans tp ON tr.travel_plan_id = tp.id
|
||||
WHERE tr.id = ? AND tp.created_by = ?
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [registrationId, userId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旅行活动的已通过报名数量
|
||||
* @param {number} travelPlanId - 旅行活动ID
|
||||
* @returns {Promise<number>} 已通过报名数量
|
||||
*/
|
||||
static async getApprovedCount(travelPlanId) {
|
||||
const query = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM travel_registrations
|
||||
WHERE travel_plan_id = ? AND status = 'approved'
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [travelPlanId]);
|
||||
return rows[0].count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据清理方法 - 移除敏感信息
|
||||
* @param {Object} registration - 报名记录
|
||||
* @returns {Object} 清理后的报名记录
|
||||
*/
|
||||
static sanitize(registration) {
|
||||
if (!registration) return null;
|
||||
|
||||
const sanitized = { ...registration };
|
||||
|
||||
// 移除敏感信息
|
||||
delete sanitized.emergency_phone;
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TravelRegistration;
|
||||
@@ -64,7 +64,7 @@ class UserMySQL {
|
||||
|
||||
// 更新用户信息
|
||||
static async update(id, updates) {
|
||||
const allowedFields = ['nickname', 'avatar', 'gender', 'birthday', 'phone', 'email'];
|
||||
const allowedFields = ['real_name', 'avatar_url', 'email', 'phone', 'user_type'];
|
||||
const setClauses = [];
|
||||
const params = [];
|
||||
|
||||
@@ -79,10 +79,9 @@ class UserMySQL {
|
||||
return false;
|
||||
}
|
||||
|
||||
setClauses.push('updated_at = NOW()');
|
||||
params.push(id);
|
||||
|
||||
const sql = `UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`;
|
||||
const sql = `UPDATE users SET ${setClauses.join(', ')}, updated_at = NOW() WHERE id = ?`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
@@ -96,70 +95,163 @@ class UserMySQL {
|
||||
|
||||
// 更新最后登录时间
|
||||
static async updateLastLogin(id) {
|
||||
const sql = 'UPDATE users SET updated_at = NOW() WHERE id = ?';
|
||||
const result = await query(sql, [id]);
|
||||
return result.affectedRows > 0;
|
||||
const sql = 'UPDATE users SET last_login_at = NOW() WHERE id = ?';
|
||||
await query(sql, [id]);
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
// 检查用户名是否存在
|
||||
static async isUsernameExists(username, excludeId = null) {
|
||||
let sql = 'SELECT COUNT(*) as count FROM users WHERE username = ?';
|
||||
const params = [username];
|
||||
|
||||
|
||||
if (excludeId) {
|
||||
sql += ' AND id != ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
|
||||
|
||||
const rows = await query(sql, params);
|
||||
return rows[0].count > 0;
|
||||
}
|
||||
|
||||
// 检查用户状态是否活跃
|
||||
// 检查用户是否激活
|
||||
static isActive(user) {
|
||||
return user.status === 'active';
|
||||
return user && user.status === 'active';
|
||||
}
|
||||
|
||||
// 执行原始查询(用于复杂查询)
|
||||
// 通用查询方法
|
||||
static async query(sql, params = []) {
|
||||
const { query } = require('../config/database');
|
||||
return await query(sql, params);
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
// 检查邮箱是否存在
|
||||
static async isEmailExists(email, excludeId = null) {
|
||||
if (!email) return false;
|
||||
|
||||
let sql = 'SELECT COUNT(*) as count FROM users WHERE email = ?';
|
||||
const params = [email];
|
||||
|
||||
|
||||
if (excludeId) {
|
||||
sql += ' AND id != ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
|
||||
|
||||
const rows = await query(sql, params);
|
||||
return rows[0].count > 0;
|
||||
}
|
||||
|
||||
// 检查手机号是否已存在
|
||||
// 检查手机号是否存在
|
||||
static async isPhoneExists(phone, excludeId = null) {
|
||||
if (!phone) return false;
|
||||
|
||||
let sql = 'SELECT COUNT(*) as count FROM users WHERE phone = ?';
|
||||
const params = [phone];
|
||||
|
||||
|
||||
if (excludeId) {
|
||||
sql += ' AND id != ?';
|
||||
params.push(excludeId);
|
||||
}
|
||||
|
||||
|
||||
const rows = await query(sql, params);
|
||||
return rows[0].count > 0;
|
||||
}
|
||||
|
||||
// 安全返回用户信息(去除敏感信息)
|
||||
// 清理用户数据(移除敏感信息)
|
||||
static sanitize(user) {
|
||||
if (!user) return null;
|
||||
|
||||
const { password_hash, ...safeUser } = user;
|
||||
return safeUser;
|
||||
const { password_hash, ...sanitizedUser } = user;
|
||||
return sanitizedUser;
|
||||
}
|
||||
|
||||
// 保存邮箱验证码
|
||||
static async saveVerificationCode(email, code, expiresAt) {
|
||||
const sql = `
|
||||
INSERT INTO email_verifications (email, code, expires_at, created_at)
|
||||
VALUES (?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
code = VALUES(code),
|
||||
expires_at = VALUES(expires_at),
|
||||
created_at = NOW()
|
||||
`;
|
||||
return await query(sql, [email, code, expiresAt]);
|
||||
}
|
||||
|
||||
// 验证邮箱验证码
|
||||
static async verifyEmailCode(email, code) {
|
||||
const sql = `
|
||||
SELECT * FROM email_verifications
|
||||
WHERE email = ? AND code = ? AND expires_at > NOW()
|
||||
`;
|
||||
const rows = await query(sql, [email, code]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// 删除邮箱验证码
|
||||
static async deleteVerificationCode(email, code) {
|
||||
const sql = 'DELETE FROM email_verifications WHERE email = ? AND code = ?';
|
||||
return await query(sql, [email, code]);
|
||||
}
|
||||
|
||||
// 保存密码重置token
|
||||
static async savePasswordResetToken(userId, token, expiresAt) {
|
||||
const sql = `
|
||||
INSERT INTO password_resets (user_id, token, expires_at, created_at)
|
||||
VALUES (?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
token = VALUES(token),
|
||||
expires_at = VALUES(expires_at),
|
||||
created_at = NOW()
|
||||
`;
|
||||
return await query(sql, [userId, token, expiresAt]);
|
||||
}
|
||||
|
||||
// 查找密码重置token
|
||||
static async findPasswordResetToken(token) {
|
||||
const sql = `
|
||||
SELECT * FROM password_resets
|
||||
WHERE token = ? AND expires_at > NOW()
|
||||
`;
|
||||
const rows = await query(sql, [token]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// 删除密码重置token
|
||||
static async deletePasswordResetToken(token) {
|
||||
const sql = 'DELETE FROM password_resets WHERE token = ?';
|
||||
return await query(sql, [token]);
|
||||
}
|
||||
|
||||
// 记录登录失败次数
|
||||
static async recordLoginFailure(identifier) {
|
||||
const sql = `
|
||||
INSERT INTO login_attempts (identifier, attempts, last_attempt, created_at)
|
||||
VALUES (?, 1, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
attempts = attempts + 1,
|
||||
last_attempt = NOW()
|
||||
`;
|
||||
return await query(sql, [identifier]);
|
||||
}
|
||||
|
||||
// 获取登录失败次数
|
||||
static async getLoginAttempts(identifier) {
|
||||
const sql = `
|
||||
SELECT attempts, last_attempt FROM login_attempts
|
||||
WHERE identifier = ? AND last_attempt > DATE_SUB(NOW(), INTERVAL 1 HOUR)
|
||||
`;
|
||||
const rows = await query(sql, [identifier]);
|
||||
return rows[0] || { attempts: 0 };
|
||||
}
|
||||
|
||||
// 清除登录失败记录
|
||||
static async clearLoginAttempts(identifier) {
|
||||
const sql = 'DELETE FROM login_attempts WHERE identifier = ?';
|
||||
return await query(sql, [identifier]);
|
||||
}
|
||||
|
||||
// 检查账户是否被锁定
|
||||
static async isAccountLocked(identifier) {
|
||||
const attempts = await this.getLoginAttempts(identifier);
|
||||
return attempts.attempts >= 5; // 5次失败后锁定
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,12 @@ const adminController = require('../controllers/admin');
|
||||
const systemStatsController = require('../controllers/admin/systemStats');
|
||||
const { authenticateAdmin } = require('../middleware/auth');
|
||||
|
||||
// 引入子路由
|
||||
const userManagementRoutes = require('./admin/userManagement');
|
||||
const dataStatisticsRoutes = require('./admin/dataStatistics');
|
||||
const animalManagementRoutes = require('./admin/animalManagement');
|
||||
const fileManagementRoutes = require('./admin/fileManagement');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
@@ -683,4 +689,10 @@ router.get('/system/order-stats', authenticateAdmin, systemStatsController.getOr
|
||||
*/
|
||||
router.get('/system/info', authenticateAdmin, systemStatsController.getSystemInfo);
|
||||
|
||||
// 注册子路由
|
||||
router.use('/users', userManagementRoutes);
|
||||
router.use('/statistics', dataStatisticsRoutes);
|
||||
router.use('/animals', animalManagementRoutes);
|
||||
router.use('/files', fileManagementRoutes);
|
||||
|
||||
module.exports = router;
|
||||
611
backend/src/routes/admin/animalManagement.js
Normal file
611
backend/src/routes/admin/animalManagement.js
Normal file
@@ -0,0 +1,611 @@
|
||||
const express = require('express');
|
||||
const { body, query, param } = require('express-validator');
|
||||
const AnimalManagementController = require('../../controllers/admin/animalManagement');
|
||||
const { requireRole } = require('../../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Admin Animal Management
|
||||
* description: 管理员动物管理相关接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* AnimalDetail:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* name:
|
||||
* type: string
|
||||
* description: 动物名称
|
||||
* species:
|
||||
* type: string
|
||||
* description: 动物物种
|
||||
* breed:
|
||||
* type: string
|
||||
* description: 品种
|
||||
* age:
|
||||
* type: integer
|
||||
* description: 年龄(月)
|
||||
* gender:
|
||||
* type: string
|
||||
* enum: [male, female]
|
||||
* description: 性别
|
||||
* price:
|
||||
* type: number
|
||||
* description: 认领价格
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [available, claimed, unavailable]
|
||||
* description: 状态
|
||||
* description:
|
||||
* type: string
|
||||
* description: 动物描述
|
||||
* images:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: 动物图片
|
||||
* merchant_id:
|
||||
* type: integer
|
||||
* description: 商家ID
|
||||
* merchant_name:
|
||||
* type: string
|
||||
* description: 商家名称
|
||||
* claim_count:
|
||||
* type: integer
|
||||
* description: 被认领次数
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 更新时间
|
||||
* AnimalStatistics:
|
||||
* type: object
|
||||
* properties:
|
||||
* totalStats:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_animals:
|
||||
* type: integer
|
||||
* description: 动物总数
|
||||
* available_animals:
|
||||
* type: integer
|
||||
* description: 可认领动物数
|
||||
* claimed_animals:
|
||||
* type: integer
|
||||
* description: 已认领动物数
|
||||
* total_claims:
|
||||
* type: integer
|
||||
* description: 总认领次数
|
||||
* avg_price:
|
||||
* type: number
|
||||
* description: 平均价格
|
||||
* speciesStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* species:
|
||||
* type: string
|
||||
* count:
|
||||
* type: integer
|
||||
* avg_price:
|
||||
* type: number
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals:
|
||||
* get:
|
||||
* summary: 获取动物列表
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 搜索关键词
|
||||
* - in: query
|
||||
* name: species
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 动物物种
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [available, claimed, unavailable]
|
||||
* description: 动物状态
|
||||
* - in: query
|
||||
* name: merchant_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 商家ID
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: sort_by
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [created_at, updated_at, price, claim_count]
|
||||
* default: created_at
|
||||
* description: 排序字段
|
||||
* - in: query
|
||||
* name: sort_order
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [asc, desc]
|
||||
* default: desc
|
||||
* description: 排序方向
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* animals:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalDetail'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('keyword').optional().isString(),
|
||||
query('species').optional().isString(),
|
||||
query('status').optional().isIn(['available', 'claimed', 'unavailable']),
|
||||
query('merchant_id').optional().isInt(),
|
||||
query('start_date').optional().isDate(),
|
||||
query('end_date').optional().isDate(),
|
||||
query('sort_by').optional().isIn(['created_at', 'updated_at', 'price', 'claim_count']),
|
||||
query('sort_order').optional().isIn(['asc', 'desc'])
|
||||
],
|
||||
AnimalManagementController.getAnimalList
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/{animal_id}:
|
||||
* get:
|
||||
* summary: 获取动物详情
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: animal_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* animal:
|
||||
* $ref: '#/components/schemas/AnimalDetail'
|
||||
* claimStats:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_claims:
|
||||
* type: integer
|
||||
* pending_claims:
|
||||
* type: integer
|
||||
* approved_claims:
|
||||
* type: integer
|
||||
* rejected_claims:
|
||||
* type: integer
|
||||
* recentClaims:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 动物不存在
|
||||
*/
|
||||
router.get('/:animal_id',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('animal_id').isInt({ min: 1 })
|
||||
],
|
||||
AnimalManagementController.getAnimalDetail
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/{animal_id}/status:
|
||||
* put:
|
||||
* summary: 更新动物状态
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: animal_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [available, claimed, unavailable]
|
||||
* description: 新状态
|
||||
* reason:
|
||||
* type: string
|
||||
* description: 状态变更原因
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 更新成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 动物不存在
|
||||
*/
|
||||
router.put('/:animal_id/status',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('animal_id').isInt({ min: 1 }),
|
||||
body('status').isIn(['available', 'claimed', 'unavailable']),
|
||||
body('reason').optional().isString().isLength({ max: 500 })
|
||||
],
|
||||
AnimalManagementController.updateAnimalStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/batch/status:
|
||||
* put:
|
||||
* summary: 批量更新动物状态
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - animal_ids
|
||||
* - status
|
||||
* properties:
|
||||
* animal_ids:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* description: 动物ID列表
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [available, claimed, unavailable]
|
||||
* description: 新状态
|
||||
* reason:
|
||||
* type: string
|
||||
* description: 状态变更原因
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 批量更新成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* updated_count:
|
||||
* type: integer
|
||||
* description: 更新的动物数量
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.put('/batch/status',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
body('animal_ids').isArray({ min: 1 }),
|
||||
body('animal_ids.*').isInt({ min: 1 }),
|
||||
body('status').isIn(['available', 'claimed', 'unavailable']),
|
||||
body('reason').optional().isString().isLength({ max: 500 })
|
||||
],
|
||||
AnimalManagementController.batchUpdateAnimalStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/statistics:
|
||||
* get:
|
||||
* summary: 获取动物统计信息
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/AnimalStatistics'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/statistics',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
AnimalManagementController.getAnimalStatistics
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/export:
|
||||
* get:
|
||||
* summary: 导出动物数据
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: format
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [csv, json]
|
||||
* default: csv
|
||||
* description: 导出格式
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 搜索关键词
|
||||
* - in: query
|
||||
* name: species
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 动物物种
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [available, claimed, unavailable]
|
||||
* description: 动物状态
|
||||
* - in: query
|
||||
* name: merchant_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 商家ID
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 导出成功
|
||||
* content:
|
||||
* text/csv:
|
||||
* schema:
|
||||
* type: string
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* animals:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalDetail'
|
||||
* export_time:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* total_count:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/export',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('format').optional().isIn(['csv', 'json']),
|
||||
query('keyword').optional().isString(),
|
||||
query('species').optional().isString(),
|
||||
query('status').optional().isIn(['available', 'claimed', 'unavailable']),
|
||||
query('merchant_id').optional().isInt(),
|
||||
query('start_date').optional().isDate(),
|
||||
query('end_date').optional().isDate()
|
||||
],
|
||||
AnimalManagementController.exportAnimalData
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/animals/{animal_id}/claims:
|
||||
* get:
|
||||
* summary: 获取动物认领记录
|
||||
* tags: [Admin Animal Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: animal_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 认领状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* claims:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/:animal_id/claims',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('animal_id').isInt({ min: 1 }),
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('status').optional().isIn(['pending', 'approved', 'rejected', 'cancelled'])
|
||||
],
|
||||
AnimalManagementController.getAnimalClaimRecords
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
522
backend/src/routes/admin/dataStatistics.js
Normal file
522
backend/src/routes/admin/dataStatistics.js
Normal file
@@ -0,0 +1,522 @@
|
||||
const express = require('express');
|
||||
const { query } = require('express-validator');
|
||||
const DataStatisticsController = require('../../controllers/admin/dataStatistics');
|
||||
const { requireRole } = require('../../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Admin Data Statistics
|
||||
* description: 管理员数据统计相关接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* SystemOverview:
|
||||
* type: object
|
||||
* properties:
|
||||
* users:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_users:
|
||||
* type: integer
|
||||
* description: 用户总数
|
||||
* active_users:
|
||||
* type: integer
|
||||
* description: 活跃用户数
|
||||
* new_users_today:
|
||||
* type: integer
|
||||
* description: 今日新增用户
|
||||
* new_users_week:
|
||||
* type: integer
|
||||
* description: 本周新增用户
|
||||
* travels:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_travels:
|
||||
* type: integer
|
||||
* description: 旅行总数
|
||||
* published_travels:
|
||||
* type: integer
|
||||
* description: 已发布旅行
|
||||
* new_travels_today:
|
||||
* type: integer
|
||||
* description: 今日新增旅行
|
||||
* animals:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_animals:
|
||||
* type: integer
|
||||
* description: 动物总数
|
||||
* available_animals:
|
||||
* type: integer
|
||||
* description: 可认领动物
|
||||
* claimed_animals:
|
||||
* type: integer
|
||||
* description: 已认领动物
|
||||
* orders:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_orders:
|
||||
* type: integer
|
||||
* description: 订单总数
|
||||
* completed_orders:
|
||||
* type: integer
|
||||
* description: 已完成订单
|
||||
* total_revenue:
|
||||
* type: number
|
||||
* description: 总收入
|
||||
* TrendData:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 日期
|
||||
* new_users:
|
||||
* type: integer
|
||||
* description: 新增用户数
|
||||
* cumulative_users:
|
||||
* type: integer
|
||||
* description: 累计用户数
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/overview:
|
||||
* get:
|
||||
* summary: 获取系统概览统计
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* $ref: '#/components/schemas/SystemOverview'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/overview',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
DataStatisticsController.getSystemOverview
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/user-growth:
|
||||
* get:
|
||||
* summary: 获取用户增长趋势
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: period
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [7d, 30d, 90d, 365d]
|
||||
* default: 30d
|
||||
* description: 统计周期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* period:
|
||||
* type: string
|
||||
* trendData:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/TrendData'
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/user-growth',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('period').optional().isIn(['7d', '30d', '90d', '365d'])
|
||||
],
|
||||
DataStatisticsController.getUserGrowthTrend
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/business:
|
||||
* get:
|
||||
* summary: 获取业务数据统计
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: period
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [7d, 30d, 90d]
|
||||
* default: 30d
|
||||
* description: 统计周期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* period:
|
||||
* type: string
|
||||
* travelStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* new_travels:
|
||||
* type: integer
|
||||
* published_travels:
|
||||
* type: integer
|
||||
* matched_travels:
|
||||
* type: integer
|
||||
* claimStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* new_claims:
|
||||
* type: integer
|
||||
* approved_claims:
|
||||
* type: integer
|
||||
* rejected_claims:
|
||||
* type: integer
|
||||
* orderStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* new_orders:
|
||||
* type: integer
|
||||
* completed_orders:
|
||||
* type: integer
|
||||
* daily_revenue:
|
||||
* type: number
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/business',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('period').optional().isIn(['7d', '30d', '90d'])
|
||||
],
|
||||
DataStatisticsController.getBusinessStatistics
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/geographic:
|
||||
* get:
|
||||
* summary: 获取地域分布统计
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* userDistribution:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* province:
|
||||
* type: string
|
||||
* city:
|
||||
* type: string
|
||||
* user_count:
|
||||
* type: integer
|
||||
* provinceStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* province:
|
||||
* type: string
|
||||
* user_count:
|
||||
* type: integer
|
||||
* farmer_count:
|
||||
* type: integer
|
||||
* merchant_count:
|
||||
* type: integer
|
||||
* destinationStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* destination:
|
||||
* type: string
|
||||
* travel_count:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/geographic',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
DataStatisticsController.getGeographicDistribution
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/user-behavior:
|
||||
* get:
|
||||
* summary: 获取用户行为分析
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* activityStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* activity_level:
|
||||
* type: string
|
||||
* user_count:
|
||||
* type: integer
|
||||
* levelDistribution:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* level:
|
||||
* type: string
|
||||
* user_count:
|
||||
* type: integer
|
||||
* avg_points:
|
||||
* type: number
|
||||
* avg_travel_count:
|
||||
* type: number
|
||||
* avg_claim_count:
|
||||
* type: number
|
||||
* behaviorStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* behavior_type:
|
||||
* type: string
|
||||
* user_count:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/user-behavior',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
DataStatisticsController.getUserBehaviorAnalysis
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/revenue:
|
||||
* get:
|
||||
* summary: 获取收入统计
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: period
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [7d, 30d, 90d, 365d]
|
||||
* default: 30d
|
||||
* description: 统计周期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* period:
|
||||
* type: string
|
||||
* revenueTrend:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* daily_revenue:
|
||||
* type: number
|
||||
* completed_orders:
|
||||
* type: integer
|
||||
* total_orders:
|
||||
* type: integer
|
||||
* revenueSource:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* order_type:
|
||||
* type: string
|
||||
* order_count:
|
||||
* type: integer
|
||||
* total_revenue:
|
||||
* type: number
|
||||
* avg_order_value:
|
||||
* type: number
|
||||
* paymentMethodStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* payment_method:
|
||||
* type: string
|
||||
* order_count:
|
||||
* type: integer
|
||||
* total_amount:
|
||||
* type: number
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/revenue',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('period').optional().isIn(['7d', '30d', '90d', '365d'])
|
||||
],
|
||||
DataStatisticsController.getRevenueStatistics
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/statistics/export:
|
||||
* get:
|
||||
* summary: 导出统计报告
|
||||
* tags: [Admin Data Statistics]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: reportType
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [overview, users, revenue]
|
||||
* default: overview
|
||||
* description: 报告类型
|
||||
* - in: query
|
||||
* name: period
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [7d, 30d, 90d]
|
||||
* default: 30d
|
||||
* description: 统计周期
|
||||
* - in: query
|
||||
* name: format
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [csv, json]
|
||||
* default: csv
|
||||
* description: 导出格式
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 导出成功
|
||||
* content:
|
||||
* text/csv:
|
||||
* schema:
|
||||
* type: string
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/export',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('reportType').optional().isIn(['overview', 'users', 'revenue']),
|
||||
query('period').optional().isIn(['7d', '30d', '90d']),
|
||||
query('format').optional().isIn(['csv', 'json'])
|
||||
],
|
||||
DataStatisticsController.exportStatisticsReport
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
601
backend/src/routes/admin/fileManagement.js
Normal file
601
backend/src/routes/admin/fileManagement.js
Normal file
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* 管理员文件管理路由
|
||||
* 定义文件上传、管理、统计等API接口
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getFileList,
|
||||
getFileDetail,
|
||||
deleteFileById,
|
||||
batchDeleteFiles,
|
||||
getFileStatistics,
|
||||
cleanupUnusedFiles,
|
||||
uploadFile
|
||||
} = require('../../controllers/admin/fileManagement');
|
||||
const { uploadMiddlewares, imageProcessors } = require('../../middleware/upload');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* FileInfo:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: 文件ID(Base64编码的文件路径)
|
||||
* filename:
|
||||
* type: string
|
||||
* description: 文件名
|
||||
* originalName:
|
||||
* type: string
|
||||
* description: 原始文件名
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [avatar, animal, travel, document]
|
||||
* description: 文件类型
|
||||
* size:
|
||||
* type: integer
|
||||
* description: 文件大小(字节)
|
||||
* mimetype:
|
||||
* type: string
|
||||
* description: MIME类型
|
||||
* isImage:
|
||||
* type: boolean
|
||||
* description: 是否为图片
|
||||
* url:
|
||||
* type: string
|
||||
* description: 文件访问URL
|
||||
* thumbnailUrl:
|
||||
* type: string
|
||||
* description: 缩略图URL(仅图片)
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
* modified_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 修改时间
|
||||
*
|
||||
* FileStatistics:
|
||||
* type: object
|
||||
* properties:
|
||||
* totalFiles:
|
||||
* type: integer
|
||||
* description: 文件总数
|
||||
* totalSize:
|
||||
* type: integer
|
||||
* description: 总大小(字节)
|
||||
* typeStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* description: 文件类型
|
||||
* count:
|
||||
* type: integer
|
||||
* description: 文件数量
|
||||
* size:
|
||||
* type: integer
|
||||
* description: 总大小
|
||||
* avgSize:
|
||||
* type: integer
|
||||
* description: 平均大小
|
||||
* sizeDistribution:
|
||||
* type: object
|
||||
* properties:
|
||||
* small:
|
||||
* type: integer
|
||||
* description: 小文件数量(<1MB)
|
||||
* medium:
|
||||
* type: integer
|
||||
* description: 中等文件数量(1-5MB)
|
||||
* large:
|
||||
* type: integer
|
||||
* description: 大文件数量(>5MB)
|
||||
* formatStats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* format:
|
||||
* type: string
|
||||
* description: 文件格式
|
||||
* count:
|
||||
* type: integer
|
||||
* description: 数量
|
||||
* size:
|
||||
* type: integer
|
||||
* description: 总大小
|
||||
* percentage:
|
||||
* type: string
|
||||
* description: 占比百分比
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files:
|
||||
* get:
|
||||
* summary: 获取文件列表
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: type
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [all, avatar, animal, travel, document]
|
||||
* default: all
|
||||
* description: 文件类型
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 搜索关键词
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: sort_by
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [created_at, modified_at, size, filename]
|
||||
* default: created_at
|
||||
* description: 排序字段
|
||||
* - in: query
|
||||
* name: sort_order
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [asc, desc]
|
||||
* default: desc
|
||||
* description: 排序方向
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
*/
|
||||
router.get('/', getFileList);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/{file_id}:
|
||||
* get:
|
||||
* summary: 获取文件详情
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: file_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 文件ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* file:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
* 404:
|
||||
* description: 文件不存在
|
||||
*/
|
||||
router.get('/:file_id', getFileDetail);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/{file_id}:
|
||||
* delete:
|
||||
* summary: 删除文件
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: file_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 文件ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 删除成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* 404:
|
||||
* description: 文件不存在
|
||||
*/
|
||||
router.delete('/:file_id', deleteFileById);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/batch/delete:
|
||||
* post:
|
||||
* summary: 批量删除文件
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - file_ids
|
||||
* properties:
|
||||
* file_ids:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: 文件ID列表(最多50个)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 批量删除完成
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* file_id:
|
||||
* type: string
|
||||
* filename:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
* failed:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* file_id:
|
||||
* type: string
|
||||
* filename:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
*/
|
||||
router.post('/batch/delete', batchDeleteFiles);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/statistics:
|
||||
* get:
|
||||
* summary: 获取文件统计信息
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/FileStatistics'
|
||||
*/
|
||||
router.get('/statistics', getFileStatistics);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/cleanup:
|
||||
* post:
|
||||
* summary: 清理无用文件
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: dry_run
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: true
|
||||
* description: 是否为试运行(不实际删除文件)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 清理完成
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* scanned:
|
||||
* type: integer
|
||||
* description: 扫描的文件数量
|
||||
* unused:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* filename:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* size:
|
||||
* type: integer
|
||||
* lastModified:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* deleted:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* filename:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* size:
|
||||
* type: integer
|
||||
* errors:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* filename:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* error:
|
||||
* type: string
|
||||
*/
|
||||
router.post('/cleanup', cleanupUnusedFiles);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/upload/avatar:
|
||||
* post:
|
||||
* summary: 上传头像
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* avatar:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: 头像文件(支持jpg、png格式,最大2MB)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 上传成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
*/
|
||||
router.post('/upload/avatar', uploadMiddlewares.avatar, imageProcessors.avatar, uploadFile);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/upload/animal:
|
||||
* post:
|
||||
* summary: 上传动物图片
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* images:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: 动物图片文件(支持jpg、png、gif、webp格式,最大5MB,最多5张)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 上传成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
*/
|
||||
router.post('/upload/animal', uploadMiddlewares.animalImages, imageProcessors.animal, uploadFile);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/upload/travel:
|
||||
* post:
|
||||
* summary: 上传旅行图片
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* images:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: 旅行图片文件(支持jpg、png、gif、webp格式,最大5MB,最多10张)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 上传成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
*/
|
||||
router.post('/upload/travel', uploadMiddlewares.travelImages, imageProcessors.travel, uploadFile);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/files/upload/document:
|
||||
* post:
|
||||
* summary: 上传文档
|
||||
* tags: [管理员-文件管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* documents:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: 文档文件(支持pdf、doc、docx、xls、xlsx、txt格式,最大10MB,最多3个)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 上传成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/FileInfo'
|
||||
*/
|
||||
router.post('/upload/document', uploadMiddlewares.documents, uploadFile);
|
||||
|
||||
module.exports = router;
|
||||
504
backend/src/routes/admin/userManagement.js
Normal file
504
backend/src/routes/admin/userManagement.js
Normal file
@@ -0,0 +1,504 @@
|
||||
const express = require('express');
|
||||
const { body, query, param } = require('express-validator');
|
||||
const UserManagementController = require('../../controllers/admin/userManagement');
|
||||
const { requireRole } = require('../../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Admin User Management
|
||||
* description: 管理员用户管理相关接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* UserDetail:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* nickname:
|
||||
* type: string
|
||||
* description: 用户昵称
|
||||
* phone:
|
||||
* type: string
|
||||
* description: 手机号
|
||||
* email:
|
||||
* type: string
|
||||
* description: 邮箱
|
||||
* user_type:
|
||||
* type: string
|
||||
* enum: [farmer, merchant]
|
||||
* description: 用户类型
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [active, inactive, banned]
|
||||
* description: 用户状态
|
||||
* travel_count:
|
||||
* type: integer
|
||||
* description: 旅行次数
|
||||
* animal_claim_count:
|
||||
* type: integer
|
||||
* description: 认领次数
|
||||
* points:
|
||||
* type: integer
|
||||
* description: 积分
|
||||
* level:
|
||||
* type: string
|
||||
* enum: [bronze, silver, gold, platinum]
|
||||
* description: 用户等级
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
* last_login_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 最后登录时间
|
||||
* UserStatistics:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_users:
|
||||
* type: integer
|
||||
* description: 用户总数
|
||||
* active_users:
|
||||
* type: integer
|
||||
* description: 活跃用户数
|
||||
* new_users_today:
|
||||
* type: integer
|
||||
* description: 今日新增用户
|
||||
* new_users_week:
|
||||
* type: integer
|
||||
* description: 本周新增用户
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users:
|
||||
* get:
|
||||
* summary: 获取用户列表
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: pageSize
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 搜索关键词(昵称、手机号、邮箱)
|
||||
* - in: query
|
||||
* name: userType
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [farmer, merchant]
|
||||
* description: 用户类型
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [active, inactive, banned]
|
||||
* description: 用户状态
|
||||
* - in: query
|
||||
* name: startDate
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: endDate
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: sortField
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [created_at, last_login_at, points, travel_count]
|
||||
* default: created_at
|
||||
* description: 排序字段
|
||||
* - in: query
|
||||
* name: sortOrder
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [asc, desc]
|
||||
* default: desc
|
||||
* description: 排序方向
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* users:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/UserDetail'
|
||||
* pagination:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* pageSize:
|
||||
* type: integer
|
||||
* total:
|
||||
* type: integer
|
||||
* totalPages:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('pageSize').optional().isInt({ min: 1, max: 100 }),
|
||||
query('userType').optional().isIn(['farmer', 'merchant']),
|
||||
query('status').optional().isIn(['active', 'inactive', 'banned']),
|
||||
query('sortField').optional().isIn(['created_at', 'last_login_at', 'points', 'travel_count']),
|
||||
query('sortOrder').optional().isIn(['asc', 'desc'])
|
||||
],
|
||||
UserManagementController.getUserList
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users/{userId}:
|
||||
* get:
|
||||
* summary: 获取用户详情
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: userId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* user:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/UserDetail'
|
||||
* - type: object
|
||||
* properties:
|
||||
* interests:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: 用户兴趣
|
||||
* recentTravels:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* description: 最近旅行记录
|
||||
* recentClaims:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* description: 最近认领记录
|
||||
* 404:
|
||||
* description: 用户不存在
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/:userId',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('userId').isInt({ min: 1 })
|
||||
],
|
||||
UserManagementController.getUserDetail
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users/{userId}/status:
|
||||
* put:
|
||||
* summary: 更新用户状态
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: userId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [active, inactive, banned]
|
||||
* description: 新状态
|
||||
* reason:
|
||||
* type: string
|
||||
* description: 操作原因
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 更新成功
|
||||
* 400:
|
||||
* description: 无效的状态值
|
||||
* 404:
|
||||
* description: 用户不存在
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.put('/:userId/status',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('userId').isInt({ min: 1 }),
|
||||
body('status').isIn(['active', 'inactive', 'banned']),
|
||||
body('reason').optional().isString()
|
||||
],
|
||||
UserManagementController.updateUserStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users/batch-status:
|
||||
* put:
|
||||
* summary: 批量更新用户状态
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - userIds
|
||||
* - status
|
||||
* properties:
|
||||
* userIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* description: 用户ID列表
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [active, inactive, banned]
|
||||
* description: 新状态
|
||||
* reason:
|
||||
* type: string
|
||||
* description: 操作原因
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 更新成功
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.put('/batch-status',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
body('userIds').isArray({ min: 1 }),
|
||||
body('userIds.*').isInt({ min: 1 }),
|
||||
body('status').isIn(['active', 'inactive', 'banned']),
|
||||
body('reason').optional().isString()
|
||||
],
|
||||
UserManagementController.batchUpdateUserStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users/statistics:
|
||||
* get:
|
||||
* summary: 获取用户统计信息
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: period
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [7d, 30d, 90d]
|
||||
* default: 30d
|
||||
* description: 统计周期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* basicStats:
|
||||
* $ref: '#/components/schemas/UserStatistics'
|
||||
* levelDistribution:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* level:
|
||||
* type: string
|
||||
* count:
|
||||
* type: integer
|
||||
* trendData:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date
|
||||
* new_users:
|
||||
* type: integer
|
||||
* new_farmers:
|
||||
* type: integer
|
||||
* new_merchants:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/statistics',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('period').optional().isIn(['7d', '30d', '90d'])
|
||||
],
|
||||
UserManagementController.getUserStatistics
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/users/export:
|
||||
* get:
|
||||
* summary: 导出用户数据
|
||||
* tags: [Admin User Management]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: format
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [csv, json]
|
||||
* default: csv
|
||||
* description: 导出格式
|
||||
* - in: query
|
||||
* name: userType
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [farmer, merchant]
|
||||
* description: 用户类型筛选
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [active, inactive, banned]
|
||||
* description: 状态筛选
|
||||
* - in: query
|
||||
* name: startDate
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: endDate
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 导出成功
|
||||
* content:
|
||||
* text/csv:
|
||||
* schema:
|
||||
* type: string
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* users:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/UserDetail'
|
||||
* total:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.get('/export',
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
query('format').optional().isIn(['csv', 'json']),
|
||||
query('userType').optional().isIn(['farmer', 'merchant']),
|
||||
query('status').optional().isIn(['active', 'inactive', 'banned'])
|
||||
],
|
||||
UserManagementController.exportUsers
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
656
backend/src/routes/animalClaim.js
Normal file
656
backend/src/routes/animalClaim.js
Normal file
@@ -0,0 +1,656 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const AnimalClaimController = require('../controllers/animalClaim');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* AnimalClaim:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 认领申请ID
|
||||
* claim_no:
|
||||
* type: string
|
||||
* description: 认领订单号
|
||||
* animal_id:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* animal_name:
|
||||
* type: string
|
||||
* description: 动物名称
|
||||
* animal_type:
|
||||
* type: string
|
||||
* description: 动物类型
|
||||
* animal_image:
|
||||
* type: string
|
||||
* description: 动物图片
|
||||
* user_id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* username:
|
||||
* type: string
|
||||
* description: 用户名
|
||||
* user_phone:
|
||||
* type: string
|
||||
* description: 用户手机号
|
||||
* claim_reason:
|
||||
* type: string
|
||||
* description: 认领理由
|
||||
* claim_duration:
|
||||
* type: integer
|
||||
* description: 认领时长(月)
|
||||
* total_amount:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 总金额
|
||||
* contact_info:
|
||||
* type: string
|
||||
* description: 联系方式
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 申请状态
|
||||
* start_date:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 开始日期
|
||||
* end_date:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 结束日期
|
||||
* reviewed_by:
|
||||
* type: integer
|
||||
* description: 审核人ID
|
||||
* reviewer_name:
|
||||
* type: string
|
||||
* description: 审核人姓名
|
||||
* review_remark:
|
||||
* type: string
|
||||
* description: 审核备注
|
||||
* reviewed_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 审核时间
|
||||
* approved_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 通过时间
|
||||
* cancelled_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 取消时间
|
||||
* cancel_reason:
|
||||
* type: string
|
||||
* description: 取消原因
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 更新时间
|
||||
*
|
||||
* ClaimStatistics:
|
||||
* type: object
|
||||
* properties:
|
||||
* basic:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_claims:
|
||||
* type: integer
|
||||
* description: 总申请数
|
||||
* pending_claims:
|
||||
* type: integer
|
||||
* description: 待审核申请数
|
||||
* approved_claims:
|
||||
* type: integer
|
||||
* description: 已通过申请数
|
||||
* rejected_claims:
|
||||
* type: integer
|
||||
* description: 已拒绝申请数
|
||||
* cancelled_claims:
|
||||
* type: integer
|
||||
* description: 已取消申请数
|
||||
* total_amount:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 总金额
|
||||
* avg_duration:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 平均认领时长
|
||||
* by_type:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* description: 动物类型
|
||||
* claim_count:
|
||||
* type: integer
|
||||
* description: 申请数量
|
||||
* approved_count:
|
||||
* type: integer
|
||||
* description: 通过数量
|
||||
* total_amount:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 总金额
|
||||
* by_month:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* month:
|
||||
* type: string
|
||||
* description: 月份
|
||||
* claim_count:
|
||||
* type: integer
|
||||
* description: 申请数量
|
||||
* approved_count:
|
||||
* type: integer
|
||||
* description: 通过数量
|
||||
* total_amount:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 总金额
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims:
|
||||
* post:
|
||||
* summary: 申请认领动物
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - animal_id
|
||||
* - contact_info
|
||||
* properties:
|
||||
* animal_id:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* claim_reason:
|
||||
* type: string
|
||||
* description: 认领理由
|
||||
* claim_duration:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 60
|
||||
* description: 认领时长(月,默认12个月)
|
||||
* contact_info:
|
||||
* type: string
|
||||
* description: 联系方式
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 认领申请提交成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.post('/', authenticateToken, AnimalClaimController.createClaim);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/my:
|
||||
* get:
|
||||
* summary: 获取我的认领申请列表
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 申请状态
|
||||
* - in: query
|
||||
* name: animal_type
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 动物类型
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
*/
|
||||
router.get('/my', authenticateToken, AnimalClaimController.getUserClaims);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/statistics:
|
||||
* get:
|
||||
* summary: 获取认领统计信息
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: animal_type
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 动物类型
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/ClaimStatistics'
|
||||
*/
|
||||
router.get('/statistics', authenticateToken, requireRole(['admin', 'manager']), AnimalClaimController.getClaimStatistics);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/animal/{animal_id}:
|
||||
* get:
|
||||
* summary: 获取动物的认领申请列表
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: animal_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 申请状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
*/
|
||||
router.get('/animal/:animal_id', authenticateToken, requireRole(['admin', 'manager']), AnimalClaimController.getAnimalClaims);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/check-permission/{animal_id}:
|
||||
* get:
|
||||
* summary: 检查认领权限
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: animal_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 动物ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 检查成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* can_claim:
|
||||
* type: boolean
|
||||
* description: 是否可以认领
|
||||
*/
|
||||
router.get('/check-permission/:animal_id', authenticateToken, AnimalClaimController.checkClaimPermission);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims:
|
||||
* get:
|
||||
* summary: 获取所有认领申请列表(管理员)
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 10
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 申请状态
|
||||
* - in: query
|
||||
* name: animal_type
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 动物类型
|
||||
* - in: query
|
||||
* name: user_id
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 关键词搜索(订单号、动物名称、用户名)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* pagination:
|
||||
* $ref: '#/components/schemas/Pagination'
|
||||
*/
|
||||
router.get('/', authenticateToken, requireRole(['admin', 'manager']), AnimalClaimController.getAllClaims);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/{id}/cancel:
|
||||
* put:
|
||||
* summary: 取消认领申请
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 认领申请ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 取消成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.put('/:id/cancel', authenticateToken, AnimalClaimController.cancelClaim);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/{id}/review:
|
||||
* put:
|
||||
* summary: 审核认领申请
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 认领申请ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [approved, rejected]
|
||||
* description: 审核状态
|
||||
* review_remark:
|
||||
* type: string
|
||||
* description: 审核备注
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 审核成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/AnimalClaim'
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
*/
|
||||
router.put('/:id/review', authenticateToken, requireRole(['admin', 'manager']), AnimalClaimController.reviewClaim);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/animal-claims/{id}/renew:
|
||||
* post:
|
||||
* summary: 续期认领
|
||||
* tags: [动物认领]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 认领申请ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - duration
|
||||
* - payment_method
|
||||
* properties:
|
||||
* duration:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 60
|
||||
* description: 续期时长(月)
|
||||
* payment_method:
|
||||
* type: string
|
||||
* enum: [wechat, alipay, bank_transfer]
|
||||
* description: 支付方式
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 续期申请成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* renewal:
|
||||
* type: object
|
||||
* description: 续期记录
|
||||
* amount:
|
||||
* type: number
|
||||
* format: float
|
||||
* description: 续期金额
|
||||
* message:
|
||||
* type: string
|
||||
* description: 提示信息
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.post('/:id/renew', authenticateToken, AnimalClaimController.renewClaim);
|
||||
|
||||
module.exports = router;
|
||||
@@ -330,6 +330,182 @@ router.put(
|
||||
* 500:
|
||||
* description: 服务器内部错误
|
||||
*/
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/refresh:
|
||||
* post:
|
||||
* summary: 刷新访问令牌
|
||||
* tags: [Auth]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - refreshToken
|
||||
* properties:
|
||||
* refreshToken:
|
||||
* type: string
|
||||
* description: 刷新令牌
|
||||
* example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 令牌刷新成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* description: 新的访问令牌
|
||||
* message:
|
||||
* type: string
|
||||
* example: Token刷新成功
|
||||
* 400:
|
||||
* description: 刷新令牌不能为空
|
||||
* 401:
|
||||
* description: 无效或过期的刷新令牌
|
||||
*/
|
||||
router.post('/refresh', authController.refreshToken);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/send-verification:
|
||||
* post:
|
||||
* summary: 发送邮箱验证码
|
||||
* tags: [Auth]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: 邮箱地址
|
||||
* example: user@example.com
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 验证码发送成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: 验证码已发送到您的邮箱
|
||||
* 400:
|
||||
* description: 邮箱不能为空或格式不正确
|
||||
*/
|
||||
router.post('/send-verification', authController.sendEmailVerification);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/forgot-password:
|
||||
* post:
|
||||
* summary: 忘记密码
|
||||
* tags: [Auth]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: 注册邮箱
|
||||
* example: user@example.com
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 重置链接发送成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: 如果该邮箱已注册,重置密码链接已发送到您的邮箱
|
||||
* 400:
|
||||
* description: 邮箱不能为空
|
||||
*/
|
||||
router.post('/forgot-password', authController.forgotPassword);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/reset-password:
|
||||
* post:
|
||||
* summary: 重置密码
|
||||
* tags: [Auth]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - token
|
||||
* - newPassword
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* description: 重置令牌
|
||||
* example: abc123def456...
|
||||
* newPassword:
|
||||
* type: string
|
||||
* description: 新密码
|
||||
* example: newpassword123
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 密码重置成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: 密码重置成功
|
||||
* 400:
|
||||
* description: 重置令牌无效或新密码格式错误
|
||||
*/
|
||||
router.post('/reset-password', authController.resetPassword);
|
||||
|
||||
router.post('/admin/login', authController.adminLogin);
|
||||
|
||||
/**
|
||||
|
||||
561
backend/src/routes/payment.js
Normal file
561
backend/src/routes/payment.js
Normal file
@@ -0,0 +1,561 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const PaymentController = require('../controllers/payment');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
const { body, param } = require('express-validator');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* Payment:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 支付订单ID
|
||||
* payment_no:
|
||||
* type: string
|
||||
* description: 支付订单号
|
||||
* order_id:
|
||||
* type: integer
|
||||
* description: 关联订单ID
|
||||
* user_id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 支付金额
|
||||
* paid_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 实际支付金额
|
||||
* payment_method:
|
||||
* type: string
|
||||
* enum: [wechat, alipay, balance]
|
||||
* description: 支付方式
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [pending, paid, failed, refunded, cancelled]
|
||||
* description: 支付状态
|
||||
* transaction_id:
|
||||
* type: string
|
||||
* description: 第三方交易号
|
||||
* paid_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 支付时间
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 更新时间
|
||||
*
|
||||
* Refund:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 退款ID
|
||||
* refund_no:
|
||||
* type: string
|
||||
* description: 退款订单号
|
||||
* payment_id:
|
||||
* type: integer
|
||||
* description: 支付订单ID
|
||||
* user_id:
|
||||
* type: integer
|
||||
* description: 用户ID
|
||||
* refund_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 退款金额
|
||||
* refund_reason:
|
||||
* type: string
|
||||
* description: 退款原因
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, completed]
|
||||
* description: 退款状态
|
||||
* processed_by:
|
||||
* type: integer
|
||||
* description: 处理人ID
|
||||
* process_remark:
|
||||
* type: string
|
||||
* description: 处理备注
|
||||
* processed_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 处理时间
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 创建时间
|
||||
*
|
||||
* PaymentStatistics:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 总支付金额
|
||||
* total_count:
|
||||
* type: integer
|
||||
* description: 总支付笔数
|
||||
* success_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 成功支付金额
|
||||
* success_count:
|
||||
* type: integer
|
||||
* description: 成功支付笔数
|
||||
* refund_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 退款金额
|
||||
* refund_count:
|
||||
* type: integer
|
||||
* description: 退款笔数
|
||||
* method_stats:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* payment_method:
|
||||
* type: string
|
||||
* description: 支付方式
|
||||
* amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 金额
|
||||
* count:
|
||||
* type: integer
|
||||
* description: 笔数
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments:
|
||||
* post:
|
||||
* summary: 创建支付订单
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - order_id
|
||||
* - amount
|
||||
* - payment_method
|
||||
* properties:
|
||||
* order_id:
|
||||
* type: integer
|
||||
* description: 订单ID
|
||||
* amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 支付金额
|
||||
* payment_method:
|
||||
* type: string
|
||||
* enum: [wechat, alipay, balance]
|
||||
* description: 支付方式
|
||||
* return_url:
|
||||
* type: string
|
||||
* description: 支付成功回调地址
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 支付订单创建成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Payment'
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.post('/',
|
||||
authenticateToken,
|
||||
[
|
||||
body('order_id').isInt({ min: 1 }).withMessage('订单ID必须是正整数'),
|
||||
body('amount').isFloat({ min: 0.01 }).withMessage('支付金额必须大于0'),
|
||||
body('payment_method').isIn(['wechat', 'alipay', 'balance']).withMessage('支付方式无效')
|
||||
],
|
||||
PaymentController.createPayment
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/{paymentId}:
|
||||
* get:
|
||||
* summary: 获取支付订单详情
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: paymentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 支付订单ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Payment'
|
||||
* 403:
|
||||
* description: 无权访问
|
||||
* 404:
|
||||
* description: 支付订单不存在
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.get('/:paymentId',
|
||||
authenticateToken,
|
||||
param('paymentId').isInt({ min: 1 }).withMessage('支付订单ID必须是正整数'),
|
||||
PaymentController.getPayment
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/query/{paymentNo}:
|
||||
* get:
|
||||
* summary: 查询支付状态
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: paymentNo
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: 支付订单号
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 查询成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* payment_no:
|
||||
* type: string
|
||||
* description: 支付订单号
|
||||
* status:
|
||||
* type: string
|
||||
* description: 支付状态
|
||||
* amount:
|
||||
* type: number
|
||||
* description: 支付金额
|
||||
* paid_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 支付时间
|
||||
* transaction_id:
|
||||
* type: string
|
||||
* description: 第三方交易号
|
||||
* 403:
|
||||
* description: 无权访问
|
||||
* 404:
|
||||
* description: 支付订单不存在
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.get('/query/:paymentNo',
|
||||
authenticateToken,
|
||||
PaymentController.queryPaymentStatus
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/callback/wechat:
|
||||
* post:
|
||||
* summary: 微信支付回调
|
||||
* tags: [支付管理]
|
||||
* description: 微信支付异步通知接口
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/xml:
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 处理成功
|
||||
* content:
|
||||
* application/xml:
|
||||
* schema:
|
||||
* type: string
|
||||
*/
|
||||
router.post('/callback/wechat', PaymentController.handleWechatCallback);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/callback/alipay:
|
||||
* post:
|
||||
* summary: 支付宝支付回调
|
||||
* tags: [支付管理]
|
||||
* description: 支付宝异步通知接口
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/x-www-form-urlencoded:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 处理成功
|
||||
* content:
|
||||
* text/plain:
|
||||
* schema:
|
||||
* type: string
|
||||
*/
|
||||
router.post('/callback/alipay', PaymentController.handleAlipayCallback);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/{paymentId}/refund:
|
||||
* post:
|
||||
* summary: 申请退款
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: paymentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 支付订单ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - refund_amount
|
||||
* - refund_reason
|
||||
* properties:
|
||||
* refund_amount:
|
||||
* type: number
|
||||
* format: decimal
|
||||
* description: 退款金额
|
||||
* refund_reason:
|
||||
* type: string
|
||||
* description: 退款原因
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 退款申请提交成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Refund'
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 403:
|
||||
* description: 无权操作
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.post('/:paymentId/refund',
|
||||
authenticateToken,
|
||||
[
|
||||
param('paymentId').isInt({ min: 1 }).withMessage('支付订单ID必须是正整数'),
|
||||
body('refund_amount').isFloat({ min: 0.01 }).withMessage('退款金额必须大于0'),
|
||||
body('refund_reason').notEmpty().withMessage('退款原因不能为空')
|
||||
],
|
||||
PaymentController.createRefund
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/refunds/{refundId}:
|
||||
* get:
|
||||
* summary: 获取退款详情
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: refundId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 退款ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Refund'
|
||||
* 403:
|
||||
* description: 无权访问
|
||||
* 404:
|
||||
* description: 退款记录不存在
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.get('/refunds/:refundId',
|
||||
authenticateToken,
|
||||
param('refundId').isInt({ min: 1 }).withMessage('退款ID必须是正整数'),
|
||||
PaymentController.getRefund
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/refunds/{refundId}/process:
|
||||
* put:
|
||||
* summary: 处理退款(管理员)
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: refundId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 退款ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [approved, rejected, completed]
|
||||
* description: 退款状态
|
||||
* process_remark:
|
||||
* type: string
|
||||
* description: 处理备注
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 处理成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Refund'
|
||||
* 400:
|
||||
* description: 参数错误
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.put('/refunds/:refundId/process',
|
||||
authenticateToken,
|
||||
requireRole(['admin', 'super_admin']),
|
||||
[
|
||||
param('refundId').isInt({ min: 1 }).withMessage('退款ID必须是正整数'),
|
||||
body('status').isIn(['approved', 'rejected', 'completed']).withMessage('退款状态无效')
|
||||
],
|
||||
PaymentController.processRefund
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/payments/statistics:
|
||||
* get:
|
||||
* summary: 获取支付统计信息(管理员)
|
||||
* tags: [支付管理]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: start_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 开始日期
|
||||
* - in: query
|
||||
* name: end_date
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date
|
||||
* description: 结束日期
|
||||
* - in: query
|
||||
* name: payment_method
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [wechat, alipay, balance]
|
||||
* description: 支付方式
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* $ref: '#/components/schemas/PaymentStatistics'
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 500:
|
||||
* description: 服务器错误
|
||||
*/
|
||||
router.get('/statistics',
|
||||
authenticateToken,
|
||||
requireRole(['admin', 'super_admin']),
|
||||
PaymentController.getPaymentStatistics
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
434
backend/src/routes/travelRegistration.js
Normal file
434
backend/src/routes/travelRegistration.js
Normal file
@@ -0,0 +1,434 @@
|
||||
const express = require('express');
|
||||
const { body, query } = require('express-validator');
|
||||
const TravelRegistrationController = require('../controllers/travelRegistration');
|
||||
const { authenticateUser: authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: TravelRegistration
|
||||
* description: 旅行活动报名管理相关接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* TravelRegistration:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 报名记录ID
|
||||
* travel_plan_id:
|
||||
* type: integer
|
||||
* description: 旅行活动ID
|
||||
* user_id:
|
||||
* type: integer
|
||||
* description: 报名用户ID
|
||||
* message:
|
||||
* type: string
|
||||
* description: 报名留言
|
||||
* emergency_contact:
|
||||
* type: string
|
||||
* description: 紧急联系人
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 报名状态
|
||||
* applied_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 报名时间
|
||||
* responded_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: 审核时间
|
||||
* reject_reason:
|
||||
* type: string
|
||||
* description: 拒绝原因
|
||||
* username:
|
||||
* type: string
|
||||
* description: 用户名
|
||||
* real_name:
|
||||
* type: string
|
||||
* description: 真实姓名
|
||||
* avatar_url:
|
||||
* type: string
|
||||
* description: 头像URL
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/{travelId}/register:
|
||||
* post:
|
||||
* summary: 报名参加旅行活动
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: travelId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 旅行活动ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* description: 报名留言
|
||||
* example: 希望能和大家一起愉快旅行
|
||||
* emergencyContact:
|
||||
* type: string
|
||||
* description: 紧急联系人
|
||||
* example: 张三
|
||||
* emergencyPhone:
|
||||
* type: string
|
||||
* description: 紧急联系电话
|
||||
* example: 13800138000
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 报名成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* registration:
|
||||
* $ref: '#/components/schemas/TravelRegistration'
|
||||
* message:
|
||||
* type: string
|
||||
* example: 报名成功,等待审核
|
||||
* 400:
|
||||
* description: 请求参数错误或业务逻辑错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 404:
|
||||
* description: 旅行活动不存在
|
||||
*/
|
||||
router.post('/:travelId/register',
|
||||
authenticate,
|
||||
[
|
||||
body('emergencyContact').optional().isLength({ min: 1, max: 50 }).withMessage('紧急联系人长度应在1-50字符之间'),
|
||||
body('emergencyPhone').optional().isMobilePhone('zh-CN').withMessage('紧急联系电话格式不正确'),
|
||||
body('message').optional().isLength({ max: 500 }).withMessage('报名留言不能超过500字符')
|
||||
],
|
||||
TravelRegistrationController.registerForTravel
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/{registrationId}/cancel:
|
||||
* put:
|
||||
* summary: 取消报名
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: registrationId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 报名记录ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 取消成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* example: 取消报名成功
|
||||
* 400:
|
||||
* description: 请求参数错误或业务逻辑错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 404:
|
||||
* description: 报名记录不存在
|
||||
*/
|
||||
router.put('/:registrationId/cancel', authenticate, TravelRegistrationController.cancelRegistration);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/my-registrations:
|
||||
* get:
|
||||
* summary: 获取用户的报名记录
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: pageSize
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 50
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 报名状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* registrations:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/TravelRegistration'
|
||||
* pagination:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* pageSize:
|
||||
* type: integer
|
||||
* total:
|
||||
* type: integer
|
||||
* totalPages:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
*/
|
||||
router.get('/my-registrations', authenticate, TravelRegistrationController.getUserRegistrations);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/{travelId}/registrations:
|
||||
* get:
|
||||
* summary: 获取旅行活动的报名列表(活动发起者可查看)
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: travelId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 旅行活动ID
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* description: 页码
|
||||
* - in: query
|
||||
* name: pageSize
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 50
|
||||
* description: 每页数量
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, approved, rejected, cancelled]
|
||||
* description: 报名状态
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* registrations:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/TravelRegistration'
|
||||
* pagination:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* pageSize:
|
||||
* type: integer
|
||||
* total:
|
||||
* type: integer
|
||||
* totalPages:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 旅行活动不存在
|
||||
*/
|
||||
router.get('/:travelId/registrations', authenticate, TravelRegistrationController.getTravelRegistrations);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/{registrationId}/review:
|
||||
* put:
|
||||
* summary: 审核报名申请(活动发起者操作)
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: registrationId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 报名记录ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - action
|
||||
* properties:
|
||||
* action:
|
||||
* type: string
|
||||
* enum: [approve, reject]
|
||||
* description: 审核操作
|
||||
* example: approve
|
||||
* rejectReason:
|
||||
* type: string
|
||||
* description: 拒绝原因(拒绝时必填)
|
||||
* example: 活动要求不符合
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 审核成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* registration:
|
||||
* $ref: '#/components/schemas/TravelRegistration'
|
||||
* message:
|
||||
* type: string
|
||||
* example: 审核通过
|
||||
* 400:
|
||||
* description: 请求参数错误或业务逻辑错误
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 报名记录不存在
|
||||
*/
|
||||
router.put('/:registrationId/review',
|
||||
authenticate,
|
||||
[
|
||||
body('action').isIn(['approve', 'reject']).withMessage('操作类型必须是approve或reject'),
|
||||
body('rejectReason').optional().isLength({ min: 1, max: 200 }).withMessage('拒绝原因长度应在1-200字符之间')
|
||||
],
|
||||
TravelRegistrationController.reviewRegistration
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /travel-registration/{travelId}/stats:
|
||||
* get:
|
||||
* summary: 获取报名统计信息
|
||||
* tags: [TravelRegistration]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: travelId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: 旅行活动ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* stats:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_applications:
|
||||
* type: integer
|
||||
* description: 总申请数
|
||||
* pending_count:
|
||||
* type: integer
|
||||
* description: 待审核数
|
||||
* approved_count:
|
||||
* type: integer
|
||||
* description: 已通过数
|
||||
* rejected_count:
|
||||
* type: integer
|
||||
* description: 已拒绝数
|
||||
* cancelled_count:
|
||||
* type: integer
|
||||
* description: 已取消数
|
||||
* 401:
|
||||
* description: 未授权
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* 404:
|
||||
* description: 旅行活动不存在
|
||||
*/
|
||||
router.get('/:travelId/stats', authenticate, TravelRegistrationController.getRegistrationStats);
|
||||
|
||||
module.exports = router;
|
||||
372
backend/src/services/animalClaim.js
Normal file
372
backend/src/services/animalClaim.js
Normal file
@@ -0,0 +1,372 @@
|
||||
const AnimalClaimModel = require('../models/AnimalClaim');
|
||||
const AnimalModel = require('../models/Animal');
|
||||
|
||||
class AnimalClaimService {
|
||||
/**
|
||||
* 申请认领动物
|
||||
* @param {Object} claimData - 认领申请数据
|
||||
* @returns {Object} 认领申请记录
|
||||
*/
|
||||
async createClaim(claimData) {
|
||||
try {
|
||||
const { animal_id, user_id, claim_reason, claim_duration, contact_info } = claimData;
|
||||
|
||||
// 检查动物是否存在且可认领
|
||||
const animal = await AnimalModel.findById(animal_id);
|
||||
if (!animal) {
|
||||
throw new Error('动物不存在');
|
||||
}
|
||||
|
||||
if (animal.status !== 'available') {
|
||||
throw new Error('该动物当前不可认领');
|
||||
}
|
||||
|
||||
// 检查用户是否已经认领过该动物
|
||||
const existingClaim = await AnimalClaimModel.findActiveClaimByUserAndAnimal(user_id, animal_id);
|
||||
if (existingClaim) {
|
||||
throw new Error('您已经认领过该动物,请勿重复申请');
|
||||
}
|
||||
|
||||
// 生成认领订单号
|
||||
const claimNo = this.generateClaimNo();
|
||||
|
||||
// 创建认领申请
|
||||
const claim = await AnimalClaimModel.create({
|
||||
claim_no: claimNo,
|
||||
animal_id,
|
||||
user_id,
|
||||
claim_reason: claim_reason || '喜欢这只动物',
|
||||
claim_duration: claim_duration || 12, // 默认12个月
|
||||
contact_info,
|
||||
status: 'pending',
|
||||
total_amount: animal.price * (claim_duration || 12)
|
||||
});
|
||||
|
||||
return this.sanitizeClaim(claim);
|
||||
} catch (error) {
|
||||
console.error('创建动物认领申请服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消认领申请
|
||||
* @param {number} claimId - 认领申请ID
|
||||
* @param {number} userId - 用户ID
|
||||
* @returns {Object} 更新后的认领申请
|
||||
*/
|
||||
async cancelClaim(claimId, userId) {
|
||||
try {
|
||||
// 获取认领申请
|
||||
const claim = await AnimalClaimModel.findById(claimId);
|
||||
if (!claim) {
|
||||
throw new Error('认领申请不存在');
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (claim.user_id !== userId) {
|
||||
throw new Error('无权操作此认领申请');
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (!['pending', 'approved'].includes(claim.status)) {
|
||||
throw new Error('当前状态不允许取消');
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
const updatedClaim = await AnimalClaimModel.updateStatus(claimId, 'cancelled', {
|
||||
cancelled_at: new Date(),
|
||||
cancel_reason: '用户主动取消'
|
||||
});
|
||||
|
||||
// 如果动物状态是已认领,需要恢复为可认领
|
||||
if (claim.status === 'approved') {
|
||||
await AnimalModel.updateStatus(claim.animal_id, 'available');
|
||||
}
|
||||
|
||||
return this.sanitizeClaim(updatedClaim);
|
||||
} catch (error) {
|
||||
console.error('取消动物认领服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的认领申请列表
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
async getUserClaims(userId, options = {}) {
|
||||
try {
|
||||
const result = await AnimalClaimModel.getUserClaims(userId, options);
|
||||
|
||||
return {
|
||||
data: result.data.map(claim => this.sanitizeClaim(claim)),
|
||||
pagination: result.pagination
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取用户认领申请服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动物的认领申请列表
|
||||
* @param {number} animalId - 动物ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
async getAnimalClaims(animalId, options = {}) {
|
||||
try {
|
||||
const result = await AnimalClaimModel.getAnimalClaims(animalId, options);
|
||||
|
||||
return {
|
||||
data: result.data.map(claim => this.sanitizeClaim(claim)),
|
||||
pagination: result.pagination
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取动物认领申请服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有认领申请列表(管理员)
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Object} 分页结果
|
||||
*/
|
||||
async getAllClaims(options = {}) {
|
||||
try {
|
||||
const result = await AnimalClaimModel.getAllClaims(options);
|
||||
|
||||
return {
|
||||
data: result.data.map(claim => this.sanitizeClaim(claim)),
|
||||
pagination: result.pagination
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取所有认领申请服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核认领申请
|
||||
* @param {number} claimId - 认领申请ID
|
||||
* @param {string} status - 审核状态
|
||||
* @param {Object} reviewData - 审核数据
|
||||
* @returns {Object} 更新后的认领申请
|
||||
*/
|
||||
async reviewClaim(claimId, status, reviewData = {}) {
|
||||
try {
|
||||
const { reviewed_by, review_remark } = reviewData;
|
||||
|
||||
// 获取认领申请
|
||||
const claim = await AnimalClaimModel.findById(claimId);
|
||||
if (!claim) {
|
||||
throw new Error('认领申请不存在');
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (claim.status !== 'pending') {
|
||||
throw new Error('只能审核待审核的申请');
|
||||
}
|
||||
|
||||
// 验证审核状态
|
||||
const validStatuses = ['approved', 'rejected'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error('无效的审核状态');
|
||||
}
|
||||
|
||||
// 更新认领申请状态
|
||||
const updateData = {
|
||||
reviewed_by,
|
||||
review_remark,
|
||||
reviewed_at: new Date()
|
||||
};
|
||||
|
||||
if (status === 'approved') {
|
||||
updateData.approved_at = new Date();
|
||||
updateData.start_date = new Date();
|
||||
|
||||
// 计算结束日期
|
||||
const endDate = new Date();
|
||||
endDate.setMonth(endDate.getMonth() + claim.claim_duration);
|
||||
updateData.end_date = endDate;
|
||||
|
||||
// 更新动物状态为已认领
|
||||
await AnimalModel.updateStatus(claim.animal_id, 'claimed');
|
||||
|
||||
// 增加动物认领次数
|
||||
await AnimalModel.incrementClaimCount(claim.animal_id);
|
||||
}
|
||||
|
||||
const updatedClaim = await AnimalClaimModel.updateStatus(claimId, status, updateData);
|
||||
|
||||
return this.sanitizeClaim(updatedClaim);
|
||||
} catch (error) {
|
||||
console.error('审核认领申请服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 续期认领
|
||||
* @param {number} claimId - 认领申请ID
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {Object} renewData - 续期数据
|
||||
* @returns {Object} 续期结果
|
||||
*/
|
||||
async renewClaim(claimId, userId, renewData) {
|
||||
try {
|
||||
const { duration, payment_method } = renewData;
|
||||
|
||||
// 获取认领申请
|
||||
const claim = await AnimalClaimModel.findById(claimId);
|
||||
if (!claim) {
|
||||
throw new Error('认领申请不存在');
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (claim.user_id !== userId) {
|
||||
throw new Error('无权操作此认领申请');
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (claim.status !== 'approved') {
|
||||
throw new Error('只有已通过的认领申请才能续期');
|
||||
}
|
||||
|
||||
// 检查是否即将到期(提前30天可以续期)
|
||||
const now = new Date();
|
||||
const endDate = new Date(claim.end_date);
|
||||
const daysUntilExpiry = Math.ceil((endDate - now) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilExpiry > 30) {
|
||||
throw new Error('距离到期还有超过30天,暂时无法续期');
|
||||
}
|
||||
|
||||
// 获取动物信息计算续期费用
|
||||
const animal = await AnimalModel.findById(claim.animal_id);
|
||||
const renewAmount = animal.price * duration;
|
||||
|
||||
// 创建续期记录
|
||||
const renewRecord = await AnimalClaimModel.createRenewal({
|
||||
claim_id: claimId,
|
||||
duration,
|
||||
amount: renewAmount,
|
||||
payment_method,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
return {
|
||||
renewal: renewRecord,
|
||||
amount: renewAmount,
|
||||
message: '续期申请已提交,请完成支付'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('续期认领服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认领统计信息
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @returns {Object} 统计信息
|
||||
*/
|
||||
async getClaimStatistics(filters = {}) {
|
||||
try {
|
||||
const statistics = await AnimalClaimModel.getClaimStatistics(filters);
|
||||
return statistics;
|
||||
} catch (error) {
|
||||
console.error('获取认领统计服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查认领权限
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} animalId - 动物ID
|
||||
* @returns {boolean} 是否有权限
|
||||
*/
|
||||
async checkClaimPermission(userId, animalId) {
|
||||
try {
|
||||
// 检查动物是否存在
|
||||
const animal = await AnimalModel.findById(animalId);
|
||||
if (!animal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查动物状态
|
||||
if (animal.status !== 'available') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查用户是否已有活跃的认领申请
|
||||
const existingClaim = await AnimalClaimModel.findActiveClaimByUserAndAnimal(userId, animalId);
|
||||
if (existingClaim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('检查认领权限服务错误:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成认领订单号
|
||||
* @returns {string} 认领订单号
|
||||
*/
|
||||
generateClaimNo() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const timestamp = now.getTime().toString().slice(-6);
|
||||
|
||||
return `CLAIM${year}${month}${day}${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理认领申请数据
|
||||
* @param {Object} claim - 认领申请数据
|
||||
* @returns {Object} 清理后的数据
|
||||
*/
|
||||
sanitizeClaim(claim) {
|
||||
if (!claim) return null;
|
||||
|
||||
return {
|
||||
id: claim.id,
|
||||
claim_no: claim.claim_no,
|
||||
animal_id: claim.animal_id,
|
||||
animal_name: claim.animal_name,
|
||||
animal_type: claim.animal_type,
|
||||
animal_image: claim.animal_image,
|
||||
user_id: claim.user_id,
|
||||
username: claim.username,
|
||||
user_phone: claim.user_phone,
|
||||
claim_reason: claim.claim_reason,
|
||||
claim_duration: claim.claim_duration,
|
||||
total_amount: parseFloat(claim.total_amount || 0),
|
||||
contact_info: claim.contact_info,
|
||||
status: claim.status,
|
||||
start_date: claim.start_date,
|
||||
end_date: claim.end_date,
|
||||
reviewed_by: claim.reviewed_by,
|
||||
reviewer_name: claim.reviewer_name,
|
||||
review_remark: claim.review_remark,
|
||||
reviewed_at: claim.reviewed_at,
|
||||
approved_at: claim.approved_at,
|
||||
cancelled_at: claim.cancelled_at,
|
||||
cancel_reason: claim.cancel_reason,
|
||||
created_at: claim.created_at,
|
||||
updated_at: claim.updated_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AnimalClaimService();
|
||||
@@ -254,6 +254,53 @@ class OrderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付订单
|
||||
* @param {number} orderId - 订单ID
|
||||
* @param {Object} paymentData - 支付数据
|
||||
* @returns {Object} 支付结果
|
||||
*/
|
||||
async payOrder(orderId, paymentData) {
|
||||
try {
|
||||
// 获取订单信息
|
||||
const order = await this.getOrderById(orderId);
|
||||
if (!order) {
|
||||
throw new Error('订单不存在');
|
||||
}
|
||||
|
||||
// 检查订单状态
|
||||
if (order.status !== 'pending') {
|
||||
throw new Error('订单状态不允许支付');
|
||||
}
|
||||
|
||||
// 检查订单金额
|
||||
if (order.total_amount !== paymentData.amount) {
|
||||
throw new Error('支付金额与订单金额不符');
|
||||
}
|
||||
|
||||
// 创建支付订单
|
||||
const PaymentService = require('../payment');
|
||||
const payment = await PaymentService.createPayment({
|
||||
order_id: orderId,
|
||||
user_id: order.user_id,
|
||||
amount: order.total_amount,
|
||||
payment_method: paymentData.payment_method,
|
||||
return_url: paymentData.return_url
|
||||
});
|
||||
|
||||
// 更新订单状态为支付中
|
||||
await this.updateOrderStatus(orderId, 'paying');
|
||||
|
||||
return {
|
||||
payment,
|
||||
order: await this.getOrderById(orderId)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('支付订单服务错误:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单统计信息
|
||||
* @param {number} merchantId - 商家ID
|
||||
|
||||
529
backend/src/services/payment.js
Normal file
529
backend/src/services/payment.js
Normal file
@@ -0,0 +1,529 @@
|
||||
const database = require('../config/database');
|
||||
const crypto = require('crypto');
|
||||
|
||||
class PaymentService {
|
||||
/**
|
||||
* 创建支付订单
|
||||
* @param {Object} paymentData - 支付数据
|
||||
* @returns {Promise<Object>} 支付订单信息
|
||||
*/
|
||||
async createPayment(paymentData) {
|
||||
try {
|
||||
const {
|
||||
order_id,
|
||||
user_id,
|
||||
amount,
|
||||
payment_method,
|
||||
payment_channel = 'wechat'
|
||||
} = paymentData;
|
||||
|
||||
// 生成支付订单号
|
||||
const payment_no = this.generatePaymentNo();
|
||||
|
||||
const query = `
|
||||
INSERT INTO payments (
|
||||
payment_no, order_id, user_id, amount,
|
||||
payment_method, payment_channel, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'pending')
|
||||
`;
|
||||
|
||||
const params = [
|
||||
payment_no, order_id, user_id, amount,
|
||||
payment_method, payment_channel
|
||||
];
|
||||
|
||||
const result = await database.query(query, params);
|
||||
|
||||
// 获取创建的支付订单
|
||||
const payment = await this.getPaymentById(result.insertId);
|
||||
|
||||
// 根据支付方式生成支付参数
|
||||
const paymentParams = await this.generatePaymentParams(payment);
|
||||
|
||||
return {
|
||||
...payment,
|
||||
...paymentParams
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('创建支付订单失败:', error);
|
||||
throw new Error('创建支付订单失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付订单详情
|
||||
* @param {number} paymentId - 支付ID
|
||||
* @returns {Promise<Object>} 支付订单信息
|
||||
*/
|
||||
async getPaymentById(paymentId) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
p.*,
|
||||
o.order_number,
|
||||
o.total_amount as order_amount,
|
||||
u.username
|
||||
FROM payments p
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
WHERE p.id = ? AND p.is_deleted = 0
|
||||
`;
|
||||
|
||||
const [payments] = await database.query(query, [paymentId]);
|
||||
|
||||
if (payments.length === 0) {
|
||||
throw new Error('支付订单不存在');
|
||||
}
|
||||
|
||||
return payments[0];
|
||||
} catch (error) {
|
||||
console.error('获取支付订单失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付号获取支付订单
|
||||
* @param {string} paymentNo - 支付订单号
|
||||
* @returns {Promise<Object>} 支付订单信息
|
||||
*/
|
||||
async getPaymentByNo(paymentNo) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
p.*,
|
||||
o.order_number,
|
||||
o.total_amount as order_amount,
|
||||
u.username
|
||||
FROM payments p
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
WHERE p.payment_no = ? AND p.is_deleted = 0
|
||||
`;
|
||||
|
||||
const [payments] = await database.query(query, [paymentNo]);
|
||||
|
||||
if (payments.length === 0) {
|
||||
throw new Error('支付订单不存在');
|
||||
}
|
||||
|
||||
return payments[0];
|
||||
} catch (error) {
|
||||
console.error('获取支付订单失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付状态
|
||||
* @param {string} paymentNo - 支付订单号
|
||||
* @param {string} status - 支付状态
|
||||
* @param {Object} extraData - 额外数据
|
||||
* @returns {Promise<Object>} 更新后的支付订单
|
||||
*/
|
||||
async updatePaymentStatus(paymentNo, status, extraData = {}) {
|
||||
try {
|
||||
const {
|
||||
transaction_id,
|
||||
paid_at,
|
||||
failure_reason
|
||||
} = extraData;
|
||||
|
||||
let query = `
|
||||
UPDATE payments
|
||||
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
const params = [status];
|
||||
|
||||
if (transaction_id) {
|
||||
query += ', transaction_id = ?';
|
||||
params.push(transaction_id);
|
||||
}
|
||||
|
||||
if (paid_at) {
|
||||
query += ', paid_at = ?';
|
||||
params.push(paid_at);
|
||||
}
|
||||
|
||||
if (failure_reason) {
|
||||
query += ', failure_reason = ?';
|
||||
params.push(failure_reason);
|
||||
}
|
||||
|
||||
query += ' WHERE payment_no = ? AND is_deleted = 0';
|
||||
params.push(paymentNo);
|
||||
|
||||
const result = await database.query(query, params);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
throw new Error('支付订单不存在');
|
||||
}
|
||||
|
||||
return await this.getPaymentByNo(paymentNo);
|
||||
} catch (error) {
|
||||
console.error('更新支付状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付回调
|
||||
* @param {Object} callbackData - 回调数据
|
||||
* @returns {Promise<Object>} 处理结果
|
||||
*/
|
||||
async handlePaymentCallback(callbackData) {
|
||||
try {
|
||||
const {
|
||||
payment_no,
|
||||
transaction_id,
|
||||
status,
|
||||
paid_amount,
|
||||
paid_at
|
||||
} = callbackData;
|
||||
|
||||
// 获取支付订单
|
||||
const payment = await this.getPaymentByNo(payment_no);
|
||||
|
||||
// 验证金额
|
||||
if (status === 'paid' && parseFloat(paid_amount) !== parseFloat(payment.amount)) {
|
||||
throw new Error('支付金额不匹配');
|
||||
}
|
||||
|
||||
// 更新支付状态
|
||||
const updatedPayment = await this.updatePaymentStatus(payment_no, status, {
|
||||
transaction_id,
|
||||
paid_at: paid_at || new Date()
|
||||
});
|
||||
|
||||
// 如果支付成功,更新订单状态
|
||||
if (status === 'paid') {
|
||||
await this.updateOrderAfterPayment(payment.order_id);
|
||||
}
|
||||
|
||||
return updatedPayment;
|
||||
} catch (error) {
|
||||
console.error('处理支付回调失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功后更新订单状态
|
||||
* @param {number} orderId - 订单ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateOrderAfterPayment(orderId) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE orders
|
||||
SET
|
||||
payment_status = 'paid',
|
||||
order_status = 'confirmed',
|
||||
paid_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_deleted = 0
|
||||
`;
|
||||
|
||||
await database.query(query, [orderId]);
|
||||
} catch (error) {
|
||||
console.error('更新订单支付状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
* @param {Object} refundData - 退款数据
|
||||
* @returns {Promise<Object>} 退款申请结果
|
||||
*/
|
||||
async createRefund(refundData) {
|
||||
try {
|
||||
const {
|
||||
payment_id,
|
||||
refund_amount,
|
||||
refund_reason,
|
||||
user_id
|
||||
} = refundData;
|
||||
|
||||
// 获取支付订单
|
||||
const payment = await this.getPaymentById(payment_id);
|
||||
|
||||
// 验证退款金额
|
||||
if (parseFloat(refund_amount) > parseFloat(payment.amount)) {
|
||||
throw new Error('退款金额不能超过支付金额');
|
||||
}
|
||||
|
||||
// 生成退款订单号
|
||||
const refund_no = this.generateRefundNo();
|
||||
|
||||
const query = `
|
||||
INSERT INTO refunds (
|
||||
refund_no, payment_id, order_id, user_id,
|
||||
refund_amount, refund_reason, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'pending')
|
||||
`;
|
||||
|
||||
const params = [
|
||||
refund_no, payment_id, payment.order_id, user_id,
|
||||
refund_amount, refund_reason
|
||||
];
|
||||
|
||||
const result = await database.query(query, params);
|
||||
|
||||
return await this.getRefundById(result.insertId);
|
||||
} catch (error) {
|
||||
console.error('创建退款申请失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取退款详情
|
||||
* @param {number} refundId - 退款ID
|
||||
* @returns {Promise<Object>} 退款信息
|
||||
*/
|
||||
async getRefundById(refundId) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
r.*,
|
||||
p.payment_no,
|
||||
p.amount as payment_amount,
|
||||
o.order_number,
|
||||
u.username
|
||||
FROM refunds r
|
||||
LEFT JOIN payments p ON r.payment_id = p.id
|
||||
LEFT JOIN orders o ON r.order_id = o.id
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
WHERE r.id = ? AND r.is_deleted = 0
|
||||
`;
|
||||
|
||||
const [refunds] = await database.query(query, [refundId]);
|
||||
|
||||
if (refunds.length === 0) {
|
||||
throw new Error('退款记录不存在');
|
||||
}
|
||||
|
||||
return refunds[0];
|
||||
} catch (error) {
|
||||
console.error('获取退款详情失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理退款
|
||||
* @param {number} refundId - 退款ID
|
||||
* @param {string} status - 退款状态
|
||||
* @param {Object} extraData - 额外数据
|
||||
* @returns {Promise<Object>} 处理结果
|
||||
*/
|
||||
async processRefund(refundId, status, extraData = {}) {
|
||||
try {
|
||||
const {
|
||||
refund_transaction_id,
|
||||
processed_by,
|
||||
process_remark
|
||||
} = extraData;
|
||||
|
||||
let query = `
|
||||
UPDATE refunds
|
||||
SET
|
||||
status = ?,
|
||||
processed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
const params = [status];
|
||||
|
||||
if (refund_transaction_id) {
|
||||
query += ', refund_transaction_id = ?';
|
||||
params.push(refund_transaction_id);
|
||||
}
|
||||
|
||||
if (processed_by) {
|
||||
query += ', processed_by = ?';
|
||||
params.push(processed_by);
|
||||
}
|
||||
|
||||
if (process_remark) {
|
||||
query += ', process_remark = ?';
|
||||
params.push(process_remark);
|
||||
}
|
||||
|
||||
query += ' WHERE id = ? AND is_deleted = 0';
|
||||
params.push(refundId);
|
||||
|
||||
const result = await database.query(query, params);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
throw new Error('退款记录不存在');
|
||||
}
|
||||
|
||||
// 如果退款成功,更新支付和订单状态
|
||||
if (status === 'completed') {
|
||||
const refund = await this.getRefundById(refundId);
|
||||
await this.updatePaymentStatus(refund.payment_no, 'refunded');
|
||||
await this.updateOrderAfterRefund(refund.order_id);
|
||||
}
|
||||
|
||||
return await this.getRefundById(refundId);
|
||||
} catch (error) {
|
||||
console.error('处理退款失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款成功后更新订单状态
|
||||
* @param {number} orderId - 订单ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateOrderAfterRefund(orderId) {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE orders
|
||||
SET
|
||||
payment_status = 'refunded',
|
||||
order_status = 'cancelled',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_deleted = 0
|
||||
`;
|
||||
|
||||
await database.query(query, [orderId]);
|
||||
} catch (error) {
|
||||
console.error('更新订单退款状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成支付订单号
|
||||
* @returns {string} 支付订单号
|
||||
*/
|
||||
generatePaymentNo() {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `PAY${timestamp}${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成退款订单号
|
||||
* @returns {string} 退款订单号
|
||||
*/
|
||||
generateRefundNo() {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `REF${timestamp}${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成支付参数(模拟)
|
||||
* @param {Object} payment - 支付订单
|
||||
* @returns {Promise<Object>} 支付参数
|
||||
*/
|
||||
async generatePaymentParams(payment) {
|
||||
try {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
const nonceStr = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// 模拟微信支付参数
|
||||
if (payment.payment_channel === 'wechat') {
|
||||
return {
|
||||
timeStamp: timestamp,
|
||||
nonceStr: nonceStr,
|
||||
package: `prepay_id=wx${timestamp}${nonceStr}`,
|
||||
signType: 'MD5',
|
||||
paySign: this.generateSign({
|
||||
timeStamp: timestamp,
|
||||
nonceStr: nonceStr,
|
||||
package: `prepay_id=wx${timestamp}${nonceStr}`,
|
||||
signType: 'MD5'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// 模拟支付宝参数
|
||||
if (payment.payment_channel === 'alipay') {
|
||||
return {
|
||||
orderString: `app_id=2021000000000000&method=alipay.trade.app.pay&charset=utf-8&sign_type=RSA2×tamp=${timestamp}&version=1.0¬ify_url=https://api.jiebanke.com/payment/alipay/notify&biz_content={"out_trade_no":"${payment.payment_no}","total_amount":"${payment.amount}","subject":"订单支付","product_code":"QUICK_MSECURITY_PAY"}`
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error('生成支付参数失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名(模拟)
|
||||
* @param {Object} params - 参数
|
||||
* @returns {string} 签名
|
||||
*/
|
||||
generateSign(params) {
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map(key => `${key}=${params[key]}`)
|
||||
.join('&');
|
||||
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(sortedParams + '&key=your_secret_key')
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付统计信息
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @returns {Promise<Object>} 统计信息
|
||||
*/
|
||||
async getPaymentStatistics(filters = {}) {
|
||||
try {
|
||||
const { start_date, end_date, payment_method } = filters;
|
||||
|
||||
let whereClause = 'WHERE p.is_deleted = 0';
|
||||
const params = [];
|
||||
|
||||
if (start_date) {
|
||||
whereClause += ' AND p.created_at >= ?';
|
||||
params.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
whereClause += ' AND p.created_at <= ?';
|
||||
params.push(end_date);
|
||||
}
|
||||
|
||||
if (payment_method) {
|
||||
whereClause += ' AND p.payment_method = ?';
|
||||
params.push(payment_method);
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(*) as total_payments,
|
||||
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as successful_payments,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_payments,
|
||||
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded_payments,
|
||||
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as total_amount,
|
||||
AVG(CASE WHEN status = 'paid' THEN amount ELSE NULL END) as average_amount,
|
||||
payment_method,
|
||||
payment_channel
|
||||
FROM payments p
|
||||
${whereClause}
|
||||
GROUP BY payment_method, payment_channel
|
||||
`;
|
||||
|
||||
const [statistics] = await database.query(query, params);
|
||||
|
||||
return statistics;
|
||||
} catch (error) {
|
||||
console.error('获取支付统计失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new PaymentService();
|
||||
356
backend/src/services/travelRegistration.js
Normal file
356
backend/src/services/travelRegistration.js
Normal file
@@ -0,0 +1,356 @@
|
||||
const { query } = require('../config/database');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
/**
|
||||
* 旅行活动报名服务
|
||||
*/
|
||||
class TravelRegistrationService {
|
||||
/**
|
||||
* 用户报名参加旅行活动
|
||||
*/
|
||||
static async registerForTravel(registrationData) {
|
||||
try {
|
||||
const { userId, travelId, message, emergencyContact, emergencyPhone } = registrationData;
|
||||
|
||||
// 检查旅行活动是否存在且可报名
|
||||
const travelSql = `
|
||||
SELECT tp.*, u.username as organizer_name
|
||||
FROM travel_plans tp
|
||||
INNER JOIN users u ON tp.user_id = u.id
|
||||
WHERE tp.id = ? AND tp.status = 'active'
|
||||
`;
|
||||
const travels = await query(travelSql, [travelId]);
|
||||
|
||||
if (travels.length === 0) {
|
||||
throw new AppError('旅行活动不存在或已关闭', 404);
|
||||
}
|
||||
|
||||
const travel = travels[0];
|
||||
|
||||
// 检查是否为活动发起者
|
||||
if (travel.user_id === userId) {
|
||||
throw new AppError('不能报名自己发起的活动', 400);
|
||||
}
|
||||
|
||||
// 检查是否已经报名
|
||||
const existingSql = 'SELECT id FROM travel_matches WHERE travel_plan_id = ? AND user_id = ?';
|
||||
const existing = await query(existingSql, [travelId, userId]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new AppError('您已经报名过此活动', 400);
|
||||
}
|
||||
|
||||
// 检查活动是否已满员
|
||||
if (travel.current_participants >= travel.max_participants) {
|
||||
throw new AppError('活动已满员', 400);
|
||||
}
|
||||
|
||||
// 创建报名记录
|
||||
const insertSql = `
|
||||
INSERT INTO travel_matches (
|
||||
travel_plan_id, user_id, message, emergency_contact, emergency_phone,
|
||||
status, applied_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', NOW(), NOW(), NOW())
|
||||
`;
|
||||
|
||||
const result = await query(insertSql, [
|
||||
travelId, userId, message, emergencyContact, emergencyPhone
|
||||
]);
|
||||
|
||||
// 获取完整的报名信息
|
||||
const registrationSql = `
|
||||
SELECT tm.*, u.username, u.real_name, u.avatar_url, u.phone
|
||||
FROM travel_matches tm
|
||||
INNER JOIN users u ON tm.user_id = u.id
|
||||
WHERE tm.id = ?
|
||||
`;
|
||||
const registrations = await query(registrationSql, [result.insertId]);
|
||||
|
||||
return this.sanitizeRegistration(registrations[0]);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消报名
|
||||
*/
|
||||
static async cancelRegistration(registrationId, userId) {
|
||||
try {
|
||||
// 检查报名记录是否存在且属于当前用户
|
||||
const checkSql = `
|
||||
SELECT tm.*, tp.status as travel_status
|
||||
FROM travel_matches tm
|
||||
INNER JOIN travel_plans tp ON tm.travel_plan_id = tp.id
|
||||
WHERE tm.id = ? AND tm.user_id = ?
|
||||
`;
|
||||
const registrations = await query(checkSql, [registrationId, userId]);
|
||||
|
||||
if (registrations.length === 0) {
|
||||
throw new AppError('报名记录不存在', 404);
|
||||
}
|
||||
|
||||
const registration = registrations[0];
|
||||
|
||||
// 检查是否可以取消
|
||||
if (registration.status === 'cancelled') {
|
||||
throw new AppError('报名已取消', 400);
|
||||
}
|
||||
|
||||
if (registration.travel_status === 'completed') {
|
||||
throw new AppError('活动已结束,无法取消报名', 400);
|
||||
}
|
||||
|
||||
// 更新报名状态
|
||||
const updateSql = `
|
||||
UPDATE travel_matches
|
||||
SET status = 'cancelled', updated_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
await query(updateSql, [registrationId]);
|
||||
|
||||
// 如果之前是已通过状态,需要减少活动参与人数
|
||||
if (registration.status === 'approved') {
|
||||
const decreaseSql = `
|
||||
UPDATE travel_plans
|
||||
SET current_participants = current_participants - 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
await query(decreaseSql, [registration.travel_plan_id]);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的报名记录
|
||||
*/
|
||||
static async getUserRegistrations(searchParams) {
|
||||
try {
|
||||
const { userId, page = 1, pageSize = 10, status } = searchParams;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
let sql = `
|
||||
SELECT tm.*, tp.destination, tp.start_date, tp.end_date, tp.budget,
|
||||
tp.title, tp.status as travel_status, u.username as organizer_name
|
||||
FROM travel_matches tm
|
||||
INNER JOIN travel_plans tp ON tm.travel_plan_id = tp.id
|
||||
INNER JOIN users u ON tp.user_id = u.id
|
||||
WHERE tm.user_id = ?
|
||||
`;
|
||||
const params = [userId];
|
||||
|
||||
if (status) {
|
||||
sql += ' AND tm.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const countSql = `SELECT COUNT(*) as total FROM (${sql}) as count_query`;
|
||||
const countResult = await query(countSql, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 添加分页和排序
|
||||
sql += ' ORDER BY tm.applied_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const registrations = await query(sql, params);
|
||||
|
||||
return {
|
||||
registrations: registrations.map(reg => this.sanitizeRegistration(reg)),
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
total: parseInt(total),
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旅行活动的报名列表
|
||||
*/
|
||||
static async getTravelRegistrations(searchParams) {
|
||||
try {
|
||||
const { travelId, organizerId, page = 1, pageSize = 10, status } = searchParams;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 验证活动发起者权限
|
||||
const travelSql = 'SELECT user_id FROM travel_plans WHERE id = ?';
|
||||
const travels = await query(travelSql, [travelId]);
|
||||
|
||||
if (travels.length === 0) {
|
||||
throw new AppError('旅行活动不存在', 404);
|
||||
}
|
||||
|
||||
if (travels[0].user_id !== organizerId) {
|
||||
throw new AppError('无权查看此活动的报名信息', 403);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT tm.*, u.username, u.real_name, u.avatar_url, u.phone, u.gender, u.age
|
||||
FROM travel_matches tm
|
||||
INNER JOIN users u ON tm.user_id = u.id
|
||||
WHERE tm.travel_plan_id = ?
|
||||
`;
|
||||
const params = [travelId];
|
||||
|
||||
if (status) {
|
||||
sql += ' AND tm.status = ?';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const countSql = `SELECT COUNT(*) as total FROM (${sql}) as count_query`;
|
||||
const countResult = await query(countSql, params);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 添加分页和排序
|
||||
sql += ' ORDER BY tm.applied_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const registrations = await query(sql, params);
|
||||
|
||||
return {
|
||||
registrations: registrations.map(reg => this.sanitizeRegistration(reg)),
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
total: parseInt(total),
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核报名申请
|
||||
*/
|
||||
static async reviewRegistration(reviewData) {
|
||||
try {
|
||||
const { registrationId, organizerId, action, rejectReason } = reviewData;
|
||||
|
||||
// 检查报名记录和权限
|
||||
const checkSql = `
|
||||
SELECT tm.*, tp.user_id as organizer_id, tp.max_participants, tp.current_participants
|
||||
FROM travel_matches tm
|
||||
INNER JOIN travel_plans tp ON tm.travel_plan_id = tp.id
|
||||
WHERE tm.id = ?
|
||||
`;
|
||||
const registrations = await query(checkSql, [registrationId]);
|
||||
|
||||
if (registrations.length === 0) {
|
||||
throw new AppError('报名记录不存在', 404);
|
||||
}
|
||||
|
||||
const registration = registrations[0];
|
||||
|
||||
if (registration.organizer_id !== organizerId) {
|
||||
throw new AppError('无权操作此报名记录', 403);
|
||||
}
|
||||
|
||||
if (registration.status !== 'pending') {
|
||||
throw new AppError('此报名已处理过', 400);
|
||||
}
|
||||
|
||||
// 如果是通过申请,检查是否还有名额
|
||||
if (action === 'approve' && registration.current_participants >= registration.max_participants) {
|
||||
throw new AppError('活动已满员,无法通过更多申请', 400);
|
||||
}
|
||||
|
||||
// 更新报名状态
|
||||
const updateSql = `
|
||||
UPDATE travel_matches
|
||||
SET status = ?, reject_reason = ?, responded_at = NOW(), updated_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
const newStatus = action === 'approve' ? 'approved' : 'rejected';
|
||||
await query(updateSql, [newStatus, rejectReason || null, registrationId]);
|
||||
|
||||
// 如果通过申请,增加活动参与人数
|
||||
if (action === 'approve') {
|
||||
const increaseSql = `
|
||||
UPDATE travel_plans
|
||||
SET current_participants = current_participants + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
`;
|
||||
await query(increaseSql, [registration.travel_plan_id]);
|
||||
}
|
||||
|
||||
// 返回更新后的报名信息
|
||||
const resultSql = `
|
||||
SELECT tm.*, u.username, u.real_name, u.avatar_url
|
||||
FROM travel_matches tm
|
||||
INNER JOIN users u ON tm.user_id = u.id
|
||||
WHERE tm.id = ?
|
||||
`;
|
||||
const results = await query(resultSql, [registrationId]);
|
||||
|
||||
return this.sanitizeRegistration(results[0]);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取报名统计信息
|
||||
*/
|
||||
static async getRegistrationStats(travelId, organizerId) {
|
||||
try {
|
||||
// 验证权限
|
||||
const travelSql = 'SELECT user_id FROM travel_plans WHERE id = ?';
|
||||
const travels = await query(travelSql, [travelId]);
|
||||
|
||||
if (travels.length === 0) {
|
||||
throw new AppError('旅行活动不存在', 404);
|
||||
}
|
||||
|
||||
if (travels[0].user_id !== organizerId) {
|
||||
throw new AppError('无权查看此活动的统计信息', 403);
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
const statsSql = `
|
||||
SELECT
|
||||
COUNT(*) as total_applications,
|
||||
COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending_count,
|
||||
COUNT(CASE WHEN status = 'approved' THEN 1 END) as approved_count,
|
||||
COUNT(CASE WHEN status = 'rejected' THEN 1 END) as rejected_count,
|
||||
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count
|
||||
FROM travel_matches
|
||||
WHERE travel_plan_id = ?
|
||||
`;
|
||||
|
||||
const stats = await query(statsSql, [travelId]);
|
||||
return stats[0];
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理报名信息,移除敏感数据
|
||||
*/
|
||||
static sanitizeRegistration(registration) {
|
||||
if (!registration) return null;
|
||||
|
||||
const sanitized = { ...registration };
|
||||
|
||||
// 移除敏感信息
|
||||
delete sanitized.emergency_phone;
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TravelRegistrationService;
|
||||
248
backend/src/utils/email.js
Normal file
248
backend/src/utils/email.js
Normal file
@@ -0,0 +1,248 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
/**
|
||||
* 邮件发送工具类
|
||||
* 支持SMTP和其他邮件服务提供商
|
||||
*/
|
||||
class EmailService {
|
||||
constructor() {
|
||||
this.transporter = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化邮件传输器
|
||||
*/
|
||||
init() {
|
||||
try {
|
||||
// 根据环境变量配置邮件服务
|
||||
const emailConfig = {
|
||||
host: process.env.SMTP_HOST || 'smtp.qq.com',
|
||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS
|
||||
}
|
||||
};
|
||||
|
||||
// 如果没有配置SMTP,使用测试账户
|
||||
if (!process.env.SMTP_USER) {
|
||||
console.warn('⚠️ 未配置SMTP邮件服务,将使用测试模式');
|
||||
this.transporter = nodemailer.createTransporter({
|
||||
host: 'smtp.ethereal.email',
|
||||
port: 587,
|
||||
auth: {
|
||||
user: 'ethereal.user@ethereal.email',
|
||||
pass: 'ethereal.pass'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.transporter = nodemailer.createTransporter(emailConfig);
|
||||
}
|
||||
|
||||
console.log('✅ 邮件服务初始化成功');
|
||||
} catch (error) {
|
||||
console.error('❌ 邮件服务初始化失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件
|
||||
* @param {Object} options - 邮件选项
|
||||
* @param {string} options.to - 收件人邮箱
|
||||
* @param {string} options.subject - 邮件主题
|
||||
* @param {string} options.text - 纯文本内容
|
||||
* @param {string} options.html - HTML内容
|
||||
* @param {string} options.from - 发件人(可选)
|
||||
*/
|
||||
async sendEmail(options) {
|
||||
try {
|
||||
if (!this.transporter) {
|
||||
throw new Error('邮件服务未初始化');
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: options.from || process.env.SMTP_FROM || '"结伴客" <noreply@jiebanke.com>',
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
html: options.html
|
||||
};
|
||||
|
||||
const info = await this.transporter.sendMail(mailOptions);
|
||||
|
||||
console.log('📧 邮件发送成功:', {
|
||||
messageId: info.messageId,
|
||||
to: options.to,
|
||||
subject: options.subject
|
||||
});
|
||||
|
||||
// 如果是测试环境,输出预览链接
|
||||
if (process.env.NODE_ENV === 'development' && !process.env.SMTP_USER) {
|
||||
console.log('📧 邮件预览链接:', nodemailer.getTestMessageUrl(info));
|
||||
}
|
||||
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error('❌ 邮件发送失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码邮件
|
||||
* @param {string} to - 收件人邮箱
|
||||
* @param {string} code - 验证码
|
||||
* @param {number} expiresInMinutes - 过期时间(分钟)
|
||||
*/
|
||||
async sendVerificationCode(to, code, expiresInMinutes = 10) {
|
||||
const subject = '结伴客 - 邮箱验证码';
|
||||
const html = `
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="color: #2c3e50; margin: 0;">结伴客</h1>
|
||||
<p style="color: #7f8c8d; margin: 5px 0;">让旅行更有温度</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px; text-align: center;">
|
||||
<h2 style="color: #2c3e50; margin-bottom: 20px;">邮箱验证</h2>
|
||||
<p style="color: #555; margin-bottom: 30px;">您的验证码是:</p>
|
||||
<div style="background: #fff; padding: 20px; border-radius: 4px; border: 2px dashed #3498db; display: inline-block;">
|
||||
<span style="font-size: 32px; font-weight: bold; color: #3498db; letter-spacing: 5px;">${code}</span>
|
||||
</div>
|
||||
<p style="color: #7f8c8d; margin-top: 30px; font-size: 14px;">
|
||||
验证码将在 ${expiresInMinutes} 分钟后过期,请及时使用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; text-align: center;">
|
||||
<p style="color: #7f8c8d; font-size: 12px; margin: 0;">
|
||||
如果这不是您的操作,请忽略此邮件。<br>
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return this.sendEmail({ to, subject, html });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送密码重置邮件
|
||||
* @param {string} to - 收件人邮箱
|
||||
* @param {string} resetUrl - 重置链接
|
||||
* @param {number} expiresInMinutes - 过期时间(分钟)
|
||||
*/
|
||||
async sendPasswordReset(to, resetUrl, expiresInMinutes = 30) {
|
||||
const subject = '结伴客 - 密码重置';
|
||||
const html = `
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="color: #2c3e50; margin: 0;">结伴客</h1>
|
||||
<p style="color: #7f8c8d; margin: 5px 0;">让旅行更有温度</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px;">
|
||||
<h2 style="color: #2c3e50; margin-bottom: 20px;">密码重置</h2>
|
||||
<p style="color: #555; margin-bottom: 20px;">
|
||||
您请求重置密码,请点击下面的按钮重置您的密码:
|
||||
</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${resetUrl}"
|
||||
style="background: #3498db; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
|
||||
重置密码
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #7f8c8d; font-size: 14px; margin-bottom: 10px;">
|
||||
如果按钮无法点击,请复制以下链接到浏览器地址栏:
|
||||
</p>
|
||||
<p style="background: #fff; padding: 10px; border-radius: 4px; word-break: break-all; font-size: 12px; color: #555;">
|
||||
${resetUrl}
|
||||
</p>
|
||||
<p style="color: #e74c3c; font-size: 14px; margin-top: 20px;">
|
||||
此链接将在 ${expiresInMinutes} 分钟后过期。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; text-align: center;">
|
||||
<p style="color: #7f8c8d; font-size: 12px; margin: 0;">
|
||||
如果这不是您的操作,请忽略此邮件。您的密码不会被更改。<br>
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return this.sendEmail({ to, subject, html });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送欢迎邮件
|
||||
* @param {string} to - 收件人邮箱
|
||||
* @param {string} username - 用户名
|
||||
*/
|
||||
async sendWelcomeEmail(to, username) {
|
||||
const subject = '欢迎加入结伴客!';
|
||||
const html = `
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<h1 style="color: #2c3e50; margin: 0;">结伴客</h1>
|
||||
<p style="color: #7f8c8d; margin: 5px 0;">让旅行更有温度</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px;">
|
||||
<h2 style="color: #2c3e50; margin-bottom: 20px;">欢迎加入结伴客!</h2>
|
||||
<p style="color: #555; margin-bottom: 20px;">
|
||||
亲爱的 ${username},欢迎您加入结伴客大家庭!
|
||||
</p>
|
||||
<p style="color: #555; margin-bottom: 20px;">
|
||||
在这里,您可以:
|
||||
</p>
|
||||
<ul style="color: #555; margin-bottom: 20px; padding-left: 20px;">
|
||||
<li>发起或参加精彩的旅行活动</li>
|
||||
<li>认领可爱的小动物,体验农场生活</li>
|
||||
<li>结识志同道合的旅行伙伴</li>
|
||||
<li>享受专业的商家服务</li>
|
||||
</ul>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${process.env.FRONTEND_URL || 'https://jiebanke.com'}"
|
||||
style="background: #27ae60; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
|
||||
开始探索
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; text-align: center;">
|
||||
<p style="color: #7f8c8d; font-size: 12px; margin: 0;">
|
||||
感谢您选择结伴客,祝您使用愉快!<br>
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return this.sendEmail({ to, subject, html });
|
||||
}
|
||||
}
|
||||
|
||||
// 创建邮件服务实例
|
||||
const emailService = new EmailService();
|
||||
|
||||
// 导出便捷方法
|
||||
const sendEmail = (options) => emailService.sendEmail(options);
|
||||
const sendVerificationCode = (to, code, expiresInMinutes) =>
|
||||
emailService.sendVerificationCode(to, code, expiresInMinutes);
|
||||
const sendPasswordReset = (to, resetUrl, expiresInMinutes) =>
|
||||
emailService.sendPasswordReset(to, resetUrl, expiresInMinutes);
|
||||
const sendWelcomeEmail = (to, username) =>
|
||||
emailService.sendWelcomeEmail(to, username);
|
||||
|
||||
module.exports = {
|
||||
EmailService,
|
||||
emailService,
|
||||
sendEmail,
|
||||
sendVerificationCode,
|
||||
sendPasswordReset,
|
||||
sendWelcomeEmail
|
||||
};
|
||||
347
backend/src/utils/logger.js
Normal file
347
backend/src/utils/logger.js
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 日志工具模块
|
||||
* 提供统一的日志记录功能,支持不同级别的日志输出
|
||||
*/
|
||||
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// 确保日志目录存在
|
||||
const logDir = path.join(__dirname, '../../logs');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义日志格式
|
||||
*/
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json(),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
let logMessage = `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
||||
|
||||
// 如果有额外的元数据,添加到日志中
|
||||
if (Object.keys(meta).length > 0) {
|
||||
logMessage += `\n${JSON.stringify(meta, null, 2)}`;
|
||||
}
|
||||
|
||||
return logMessage;
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* 创建Winston日志器
|
||||
*/
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
defaultMeta: { service: 'jiebanke-api' },
|
||||
transports: [
|
||||
// 错误日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
)
|
||||
}),
|
||||
|
||||
// 组合日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'combined.log'),
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
)
|
||||
}),
|
||||
|
||||
// 访问日志文件
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'access.log'),
|
||||
level: 'http',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
)
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// 开发环境下添加控制台输出
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple(),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
let logMessage = `${timestamp} [${level}]: ${message}`;
|
||||
|
||||
// 如果有额外的元数据,添加到日志中
|
||||
if (Object.keys(meta).length > 0) {
|
||||
logMessage += `\n${JSON.stringify(meta, null, 2)}`;
|
||||
}
|
||||
|
||||
return logMessage;
|
||||
})
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求日志中间件
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件
|
||||
*/
|
||||
const requestLogger = (req, res, next) => {
|
||||
const start = Date.now();
|
||||
|
||||
// 记录请求开始
|
||||
logger.http('Request started', {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
userId: req.user?.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 监听响应结束
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const logLevel = res.statusCode >= 400 ? 'warn' : 'http';
|
||||
|
||||
logger.log(logLevel, 'Request completed', {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
userId: req.user?.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据库操作日志
|
||||
* @param {string} operation - 操作类型
|
||||
* @param {string} table - 表名
|
||||
* @param {Object} data - 操作数据
|
||||
* @param {Object} user - 操作用户
|
||||
*/
|
||||
const logDatabaseOperation = (operation, table, data = {}, user = null) => {
|
||||
logger.info('Database operation', {
|
||||
operation,
|
||||
table,
|
||||
data: JSON.stringify(data),
|
||||
userId: user?.id,
|
||||
userType: user?.user_type,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 业务操作日志
|
||||
* @param {string} action - 操作动作
|
||||
* @param {string} resource - 资源类型
|
||||
* @param {Object} details - 操作详情
|
||||
* @param {Object} user - 操作用户
|
||||
*/
|
||||
const logBusinessOperation = (action, resource, details = {}, user = null) => {
|
||||
logger.info('Business operation', {
|
||||
action,
|
||||
resource,
|
||||
details: JSON.stringify(details),
|
||||
userId: user?.id,
|
||||
userType: user?.user_type,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全事件日志
|
||||
* @param {string} event - 事件类型
|
||||
* @param {Object} details - 事件详情
|
||||
* @param {Object} req - 请求对象
|
||||
*/
|
||||
const logSecurityEvent = (event, details = {}, req = null) => {
|
||||
logger.warn('Security event', {
|
||||
event,
|
||||
details: JSON.stringify(details),
|
||||
ip: req?.ip,
|
||||
userAgent: req?.get('User-Agent'),
|
||||
userId: req?.user?.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 性能监控日志
|
||||
* @param {string} operation - 操作名称
|
||||
* @param {number} duration - 执行时间(毫秒)
|
||||
* @param {Object} metadata - 额外元数据
|
||||
*/
|
||||
const logPerformance = (operation, duration, metadata = {}) => {
|
||||
const level = duration > 1000 ? 'warn' : 'info';
|
||||
|
||||
logger.log(level, 'Performance monitoring', {
|
||||
operation,
|
||||
duration: `${duration}ms`,
|
||||
...metadata,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 系统事件日志
|
||||
* @param {string} event - 事件类型
|
||||
* @param {Object} details - 事件详情
|
||||
*/
|
||||
const logSystemEvent = (event, details = {}) => {
|
||||
logger.info('System event', {
|
||||
event,
|
||||
details: JSON.stringify(details),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 错误日志(带上下文)
|
||||
* @param {Error} error - 错误对象
|
||||
* @param {Object} context - 错误上下文
|
||||
*/
|
||||
const logError = (error, context = {}) => {
|
||||
logger.error('Application error', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
context: JSON.stringify(context),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 调试日志
|
||||
* @param {string} message - 调试信息
|
||||
* @param {Object} data - 调试数据
|
||||
*/
|
||||
const logDebug = (message, data = {}) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.debug(message, {
|
||||
data: JSON.stringify(data),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 日志清理任务
|
||||
* 定期清理过期的日志文件
|
||||
*/
|
||||
const cleanupLogs = () => {
|
||||
const maxAge = 30 * 24 * 60 * 60 * 1000; // 30天
|
||||
const now = Date.now();
|
||||
|
||||
fs.readdir(logDir, (err, files) => {
|
||||
if (err) {
|
||||
logger.error('Failed to read log directory', { error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(logDir, file);
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
if (err) return;
|
||||
|
||||
if (now - stats.mtime.getTime() > maxAge) {
|
||||
fs.unlink(filePath, (err) => {
|
||||
if (err) {
|
||||
logger.error('Failed to delete old log file', {
|
||||
file: filePath,
|
||||
error: err.message
|
||||
});
|
||||
} else {
|
||||
logger.info('Deleted old log file', { file: filePath });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 每天执行一次日志清理
|
||||
setInterval(cleanupLogs, 24 * 60 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* 日志统计信息
|
||||
*/
|
||||
const getLogStats = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readdir(logDir, (err, files) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalFiles: files.length,
|
||||
files: []
|
||||
};
|
||||
|
||||
let processed = 0;
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(logDir, file);
|
||||
fs.stat(filePath, (err, fileStat) => {
|
||||
if (!err) {
|
||||
stats.files.push({
|
||||
name: file,
|
||||
size: fileStat.size,
|
||||
created: fileStat.birthtime,
|
||||
modified: fileStat.mtime
|
||||
});
|
||||
}
|
||||
|
||||
processed++;
|
||||
if (processed === files.length) {
|
||||
resolve(stats);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
resolve(stats);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
logger,
|
||||
requestLogger,
|
||||
logDatabaseOperation,
|
||||
logBusinessOperation,
|
||||
logSecurityEvent,
|
||||
logPerformance,
|
||||
logSystemEvent,
|
||||
logError,
|
||||
logDebug,
|
||||
cleanupLogs,
|
||||
getLogStats
|
||||
};
|
||||
Reference in New Issue
Block a user