`;
@@ -392,7 +413,7 @@ async function loadBossDetail(append) {
}
try {
- const res = await fetch(url, { headers: headers() });
+ const res = await apiFetch(url);
if (!res.ok) { bossDetailLoading = false; return; }
const data = await res.json();
@@ -447,17 +468,11 @@ async function loadBossDetail(append) {
html += `
';
}
@@ -502,7 +517,7 @@ function setupBossDetailScroll() {
async function loadPartnerRankingPreview() {
try {
- const res = await fetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth, { headers: headers() });
+ const res = await apiFetch(API + '/api/stats/partner-ranking/preview?year=' + currentYear + '&month=' + currentMonth);
if (!res.ok) return;
const rows = await res.json();
renderPartnerRankingPreview(rows);
@@ -542,7 +557,7 @@ async function loadPartnerRankingDetail() {
}
if (params.length) url += '?' + params.join('&');
try {
- const res = await fetch(url, { headers: headers() });
+ const res = await apiFetch(url);
if (!res.ok) return;
const data = await res.json();
const rows = data.rows || [];
@@ -565,9 +580,10 @@ async function loadPartnerRankingDetail() {
const max = Math.max(...rows.map(r => r.dispatch_count), 1);
container.innerHTML = rows.map((r, i) => {
const pct = Math.max((r.dispatch_count / max) * 100, 2);
- return `
+ const partner = escapeHtml(r.partner);
+ return `
${i + 1}
-
${r.partner}
+
${partner}
${r.dispatch_count}单
`;
@@ -667,7 +683,7 @@ async function loadPartnerDetail(append) {
}
try {
- const res = await fetch(url, { headers: headers() });
+ const res = await apiFetch(url);
if (!res.ok) { partnerDetailLoading = false; return; }
const data = await res.json();
@@ -722,17 +738,11 @@ async function loadPartnerDetail(append) {
html += `
${month} 收入 ${group.total.toFixed(2)}
`;
group.records.forEach(r => {
const time = formatLocalDate(r.created_at) + ' ' + formatLocalTime(r.created_at);
- const displayCategory = r.category === '红包收入' ? '红包' : r.category;
- html += `
-
- ${displayCategory}${r.boss ? ' · ' + r.boss : ''}
- ${(r.amount || 0).toFixed(2)}
-
-
- ${r.quantity ? r.quantity + '单 × ' + (r.category === '派单' ? (r.rebate || r.amount / r.quantity).toFixed(2) + '(单价' + (r.unit_price || 0).toFixed(2) + ')' : (r.unit_price || 0).toFixed(2)) : (r.source || '')}
- ${time}
-
-
`;
+ html += renderRecordItem(r, {
+ time,
+ suffix: r.boss ? ' · ' + r.boss : '',
+ line: recordLineText(r, 'source')
+ });
});
html += '
';
}
diff --git a/server/app.js b/server/app.js
index 1614d00..d3ba575 100644
--- a/server/app.js
+++ b/server/app.js
@@ -17,7 +17,14 @@ const app = express();
// 请求日志中间件
app.use(logger.requestLogger);
-app.use(cors());
+app.use(cors({
+ origin(origin, callback) {
+ if (!origin || config.corsOrigins.includes(origin)) {
+ return callback(null, true);
+ }
+ return callback(null, false);
+ }
+}));
app.use(express.json());
app.use(compression());
app.use(express.static(path.join(__dirname, '..', 'public'), {
diff --git a/server/config.js b/server/config.js
index e2fa30b..01cb9aa 100644
--- a/server/config.js
+++ b/server/config.js
@@ -1,15 +1,31 @@
/**
* 应用配置
*/
+const isProduction = process.env.NODE_ENV === 'production';
+const jwtSecret = process.env.JWT_SECRET || '';
+
+if (isProduction && !jwtSecret) {
+ throw new Error('JWT_SECRET must be set in production');
+}
+
module.exports = {
// 服务器配置
port: process.env.PORT || 3000,
host: process.env.HOST || '0.0.0.0',
// JWT 配置
- jwtSecret: process.env.JWT_SECRET || 'gamer-order-secret-2024',
+ jwtSecret: jwtSecret || 'dev-only-gamer-order-secret',
jwtExpiresIn: '7d',
+ // 注册默认关闭,生产环境可通过 ENABLE_REGISTRATION=true 显式开启
+ enableRegistration: process.env.ENABLE_REGISTRATION === 'true',
+
+ // CORS 配置:默认同源;如需跨域,用逗号分隔配置 CORS_ORIGINS
+ corsOrigins: (process.env.CORS_ORIGINS || '')
+ .split(',')
+ .map(s => s.trim())
+ .filter(Boolean),
+
// 缓存配置
cache: {
ttl: 2 * 60 * 1000, // 缓存有效期 2 分钟
@@ -22,6 +38,10 @@ module.exports = {
maxLimit: 100 // 最大每页条数
},
+ import: {
+ maxRecords: 5000
+ },
+
// 日志配置
log: {
level: process.env.LOG_LEVEL || 'info', // debug, info, warn, error
@@ -36,4 +56,4 @@ module.exports = {
// 允许的联系人字段
contactFields: ['boss', 'partner', 'source', 'destination']
-};
\ No newline at end of file
+};
diff --git a/server/db.js b/server/db.js
index 39be8dd..6d20e66 100644
--- a/server/db.js
+++ b/server/db.js
@@ -1,5 +1,4 @@
const Database = require('better-sqlite3');
-const bcrypt = require('bcryptjs');
const path = require('path');
const logger = require('./utils/logger');
@@ -108,11 +107,17 @@ function migrateAddRebateColumn() {
* 初始化默认用户
*/
function initDefaultUser() {
+ if (process.env.NODE_ENV === 'production') {
+ logger.info('生产环境跳过默认用户初始化');
+ return;
+ }
+
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get('lizhilun');
if (!existingUser) {
+ const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync('lizhilun', 10);
db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run('lizhilun', hash);
- logger.info('默认用户 lizhilun 已创建');
+ logger.info('开发默认用户 lizhilun 已创建');
}
}
diff --git a/server/routes/auth.js b/server/routes/auth.js
index 1bbc221..3832a66 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -7,6 +7,43 @@ const logger = require('../utils/logger');
const router = express.Router();
+const loginAttempts = new Map();
+const LOGIN_WINDOW_MS = 15 * 60 * 1000;
+const LOGIN_MAX_FAILURES = 5;
+
+function normalizeLoginKey(req, username) {
+ return `${req.ip || req.socket.remoteAddress || 'unknown'}:${String(username || '').trim().toLowerCase()}`;
+}
+
+function getLoginAttempt(key) {
+ const now = Date.now();
+ const attempt = loginAttempts.get(key);
+ if (!attempt || attempt.resetAt <= now) {
+ const fresh = { failures: 0, resetAt: now + LOGIN_WINDOW_MS };
+ loginAttempts.set(key, fresh);
+ return fresh;
+ }
+ return attempt;
+}
+
+function clearExpiredLoginAttempts() {
+ const now = Date.now();
+ for (const [key, attempt] of loginAttempts) {
+ if (attempt.resetAt <= now) loginAttempts.delete(key);
+ }
+}
+
+function recordFailedLogin(key) {
+ const attempt = getLoginAttempt(key);
+ attempt.failures += 1;
+ if (loginAttempts.size > 1000) clearExpiredLoginAttempts();
+ return attempt;
+}
+
+function resetLoginAttempts(key) {
+ loginAttempts.delete(key);
+}
+
// JWT 中间件
function auth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
@@ -27,15 +64,58 @@ router.post('/login', (req, res) => {
return res.status(400).json({ error: '用户名和密码不能为空' });
}
+ const loginKey = normalizeLoginKey(req, username);
+ const attempt = getLoginAttempt(loginKey);
+ if (attempt.failures >= LOGIN_MAX_FAILURES) {
+ const retryAfter = Math.ceil((attempt.resetAt - Date.now()) / 1000);
+ res.setHeader('Retry-After', String(Math.max(retryAfter, 1)));
+ return res.status(429).json({ error: '登录失败次数过多,请稍后再试' });
+ }
+
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !bcrypt.compareSync(password, user.password)) {
+ recordFailedLogin(loginKey);
logger.warn(`登录失败: ${username}`);
return res.status(401).json({ error: '用户名或密码错误' });
}
+ resetLoginAttempts(loginKey);
const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
logger.info(`用户登录: ${username}`);
res.json({ token, username: user.username });
});
+// 注册
+router.post('/register', (req, res) => {
+ if (!config.enableRegistration) {
+ return res.status(403).json({ error: '注册已关闭' });
+ }
+
+ const { username, password } = req.body;
+
+ if (!username || !password) {
+ return res.status(400).json({ error: '用户名和密码不能为空' });
+ }
+
+ if (username.length < 2 || username.length > 20) {
+ return res.status(400).json({ error: '用户名长度需在2-20个字符之间' });
+ }
+
+ if (password.length < 6) {
+ return res.status(400).json({ error: '密码长度不能少于6个字符' });
+ }
+
+ const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
+ if (existing) {
+ return res.status(409).json({ error: '用户名已存在' });
+ }
+
+ const hash = bcrypt.hashSync(password, 10);
+ const result = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(username, hash);
+ logger.info(`新用户注册: ${username}`);
+
+ const token = jwt.sign({ id: result.lastInsertRowid, username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
+ res.json({ token, username });
+});
+
module.exports = { router, auth };
diff --git a/server/routes/io.js b/server/routes/io.js
index b3e1243..3de605e 100644
--- a/server/routes/io.js
+++ b/server/routes/io.js
@@ -1,10 +1,26 @@
const express = require('express');
const db = require('../db');
+const config = require('../config');
const logger = require('../utils/logger');
-const { parseCSVLine, AppError } = require('../utils/helpers');
+const {
+ parseCSVLine,
+ AppError,
+ validateNumber,
+ validateInList,
+ validateIsoDate,
+ optionalString
+} = require('../utils/helpers');
const router = express.Router();
+function emptyToNull(value) {
+ return value === undefined || value === null || value === '' ? null : value;
+}
+
+function nullableDbValue(value) {
+ return value === undefined || value === null || value === '' ? null : value;
+}
+
// 导出数据
router.get('/export', (req, res, next) => {
try {
@@ -89,29 +105,63 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res,
if (!lines[i].trim()) continue;
const values = parseCSVLine(lines[i]);
const record = {};
- csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; });
- if (record.amount) record.amount = parseFloat(record.amount);
- if (record.quantity) record.quantity = parseInt(record.quantity) || null;
- if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null;
- if (record.rebate) record.rebate = parseFloat(record.rebate) || null;
+ csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); });
+ if (record.amount !== null) record.amount = parseFloat(record.amount);
+ if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10);
+ if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price);
+ if (record.rebate !== null) record.rebate = parseFloat(record.rebate);
records.push(record);
}
}
- for (const r of records) {
- for (const f of requiredFields) {
- if (!r[f] && r[f] !== 0) {
- throw new AppError(`记录缺少必填字段: ${f}`, 400);
- }
- }
- if (!['income', 'expense'].includes(r.type)) {
- throw new AppError(`无效的类型: ${r.type}`, 400);
- }
+ if (records.length > config.import.maxRecords) {
+ throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400);
}
- const existingCheck = db.prepare(
- 'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?'
- );
+ const allCategories = [...config.categories.income, ...config.categories.expense];
+ const normalizedRecords = records.map((r, index) => {
+ for (const f of requiredFields) {
+ if (!r[f] && r[f] !== 0) {
+ throw new AppError(`第 ${index + 1} 条记录缺少必填字段: ${f}`, 400);
+ }
+ }
+ validateInList(r.type, 'type', ['income', 'expense']);
+ validateInList(r.category, 'category', allCategories);
+ const amount = r.category === '派单'
+ ? validateNumber(r.amount, '金额')
+ : validateNumber(r.amount, '金额', 0);
+ return {
+ type: r.type,
+ category: r.category,
+ amount,
+ quantity: r.quantity === undefined || r.quantity === null || r.quantity === '' ? null : validateNumber(r.quantity, '数量', 0),
+ unit_price: r.unit_price === undefined || r.unit_price === null || r.unit_price === '' ? null : validateNumber(r.unit_price, '单价', 0),
+ rebate: r.rebate === undefined || r.rebate === null || r.rebate === '' ? null : validateNumber(r.rebate, '返点'),
+ boss: optionalString(r.boss, '老板', 100),
+ partner: optionalString(r.partner, '陪玩', 100),
+ source: optionalString(r.source, '来源', 100),
+ destination: optionalString(r.destination, '去向', 100),
+ note: optionalString(r.note, '备注', 500),
+ created_at: validateIsoDate(r.created_at, '创建时间')
+ };
+ });
+
+ const existingCheck = db.prepare(`
+ SELECT id FROM records
+ WHERE user_id = ?
+ AND type = ?
+ AND category = ?
+ AND amount = ?
+ AND COALESCE(quantity, '') = COALESCE(?, '')
+ AND COALESCE(unit_price, '') = COALESCE(?, '')
+ AND COALESCE(rebate, '') = COALESCE(?, '')
+ AND COALESCE(boss, '') = COALESCE(?, '')
+ AND COALESCE(partner, '') = COALESCE(?, '')
+ AND COALESCE(source, '') = COALESCE(?, '')
+ AND COALESCE(destination, '') = COALESCE(?, '')
+ AND COALESCE(note, '') = COALESCE(?, '')
+ AND created_at = ?
+ `);
const insertStmt = db.prepare(`
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -120,21 +170,36 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res,
let imported = 0, skipped = 0;
const insertMany = db.transaction((recs) => {
for (const r of recs) {
- if (existingCheck.get(req.user.id, r.created_at, r.category, r.amount)) {
+ const values = [
+ req.user.id,
+ r.type,
+ r.category,
+ r.amount,
+ r.quantity,
+ r.unit_price,
+ r.rebate,
+ r.boss,
+ r.partner,
+ r.source,
+ r.destination,
+ r.note,
+ r.created_at
+ ];
+ if (existingCheck.get(...values)) {
skipped++; continue;
}
insertStmt.run(req.user.id, r.type, r.category, r.amount,
- r.quantity || null, r.unit_price || null, r.rebate || null,
- r.boss || null, r.partner || null,
- r.source || null, r.destination || null,
- r.note || null, r.created_at);
+ nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate),
+ nullableDbValue(r.boss), nullableDbValue(r.partner),
+ nullableDbValue(r.source), nullableDbValue(r.destination),
+ nullableDbValue(r.note), r.created_at);
imported++;
}
});
- insertMany(records);
+ insertMany(normalizedRecords);
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped} 条`);
- res.json({ imported, skipped, total: records.length });
+ res.json({ imported, skipped, total: normalizedRecords.length });
} catch (err) {
if (err instanceof SyntaxError) {
next(new AppError('文件解析失败: ' + err.message, 400));
diff --git a/server/routes/records.js b/server/routes/records.js
index 89f0d4f..c70457c 100644
--- a/server/routes/records.js
+++ b/server/routes/records.js
@@ -2,7 +2,17 @@ const express = require('express');
const db = require('../db');
const config = require('../config');
const logger = require('../utils/logger');
-const { getMonthRange, AppError, validateRequired, validateNumber, validateInList } = require('../utils/helpers');
+const {
+ getMonthRange,
+ AppError,
+ validateRequired,
+ validateNumber,
+ validateInList,
+ validatePage,
+ validateLimit,
+ validateIsoDate,
+ optionalString
+} = require('../utils/helpers');
const router = express.Router();
@@ -18,13 +28,34 @@ router.post('/', (req, res, next) => {
const allCategories = [...config.categories.income, ...config.categories.expense];
validateInList(category, 'category', allCategories);
- const validatedAmount = validateNumber(amount, '金额', 0);
+ // 派单的返点可为负数,因此金额也允许为负数
+ const validatedAmount = category === '派单'
+ ? validateNumber(amount, '金额')
+ : validateNumber(amount, '金额', 0);
+ const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
+ const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
+ const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
+ const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
const stmt = db.prepare(`
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
- const result = stmt.run(req.user.id, type, category, validatedAmount, quantity || null, unit_price || null, rebate || null, boss || null, partner || null, source || null, destination || null, note || null, created_at || new Date().toISOString());
+ const result = stmt.run(
+ req.user.id,
+ type,
+ category,
+ validatedAmount,
+ validatedQuantity,
+ validatedUnitPrice,
+ validatedRebate,
+ optionalString(boss, '老板', 100),
+ optionalString(partner, '陪玩', 100),
+ optionalString(source, '来源', 100),
+ optionalString(destination, '去向', 100),
+ optionalString(note, '备注', 500),
+ validatedCreatedAt
+ );
logger.info(`用户 ${req.user.username} 新增记录: ${category} ${validatedAmount}`);
res.json({ id: result.lastInsertRowid });
@@ -35,8 +66,9 @@ router.post('/', (req, res, next) => {
// 通用分页查询函数
function queryPagedRecords(userId, year, month, options) {
- const { page = 1, limit = 20 } = options;
- const offset = (Number(page) - 1) * Number(limit);
+ const page = validatePage(options.page, 1);
+ const limit = validateLimit(options.limit, config.pagination.defaultLimit, config.pagination.maxLimit);
+ const offset = (page - 1) * limit;
const { startDate, endDate } = getMonthRange(year, month);
let whereClause = `user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`;
@@ -61,9 +93,9 @@ function queryPagedRecords(userId, year, month, options) {
const records = db.prepare(
`SELECT * FROM records WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`
- ).all(...params, Number(limit), offset);
+ ).all(...params, limit, offset);
- return { records, total, page: Number(page), limit: Number(limit) };
+ return { records, total, page, limit };
}
// 接单列表
@@ -122,7 +154,9 @@ router.get('/dispatches', (req, res) => {
// 获取记录列表
router.get('/', (req, res) => {
const { type, page = 1, limit = 20 } = req.query;
- const offset = (page - 1) * limit;
+ const validatedPage = validatePage(page, 1);
+ const validatedLimit = validateLimit(limit, config.pagination.defaultLimit, config.pagination.maxLimit);
+ const offset = (validatedPage - 1) * validatedLimit;
let sql = 'SELECT * FROM records WHERE user_id = ?';
const params = [req.user.id];
if (type) {
@@ -130,7 +164,7 @@ router.get('/', (req, res) => {
params.push(type);
}
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
- params.push(Number(limit), Number(offset));
+ params.push(validatedLimit, offset);
res.json(db.prepare(sql).all(...params));
});
@@ -144,12 +178,37 @@ router.put('/:id', (req, res, next) => {
throw new AppError('无效的记录ID', 400);
}
- const validatedAmount = validateNumber(amount, '金额', 0);
+ // 获取原有记录以判断类别(派单允许负数金额)
+ const existing = db.prepare('SELECT category FROM records WHERE id = ? AND user_id = ?').get(id, req.user.id);
+ if (!existing) {
+ throw new AppError('记录不存在', 404);
+ }
+
+ const validatedAmount = existing.category === '派单'
+ ? validateNumber(amount, '金额')
+ : validateNumber(amount, '金额', 0);
+ const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
+ const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
+ const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
+ const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
db.prepare(`
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
WHERE id = ? AND user_id = ?
- `).run(quantity || null, unit_price || null, rebate || null, validatedAmount, boss || null, partner || null, source || null, destination || null, note || null, created_at || null, id, req.user.id);
+ `).run(
+ validatedQuantity,
+ validatedUnitPrice,
+ validatedRebate,
+ validatedAmount,
+ optionalString(boss, '老板', 100),
+ optionalString(partner, '陪玩', 100),
+ optionalString(source, '来源', 100),
+ optionalString(destination, '去向', 100),
+ optionalString(note, '备注', 500),
+ validatedCreatedAt,
+ id,
+ req.user.id
+ );
logger.debug(`用户 ${req.user.username} 更新记录: ${id}`);
res.json({ success: true });
diff --git a/server/utils/helpers.js b/server/utils/helpers.js
index 4f22d33..54eeaec 100644
--- a/server/utils/helpers.js
+++ b/server/utils/helpers.js
@@ -58,7 +58,7 @@ function validateRequired(obj, fields) {
function validateNumber(value, field, min, max) {
const num = Number(value);
- if (isNaN(num)) {
+ if (!Number.isFinite(num)) {
throw new AppError(`${field} 必须是数字`, 400);
}
if (min !== undefined && num < min) {
@@ -80,6 +80,11 @@ function validateString(value, field, maxLength) {
return value.trim();
}
+function optionalString(value, field, maxLength) {
+ if (value === undefined || value === null || value === '') return null;
+ return validateString(String(value), field, maxLength);
+}
+
function validateInList(value, field, list) {
if (!list.includes(value)) {
throw new AppError(`${field} 值无效`, 400);
@@ -87,6 +92,24 @@ function validateInList(value, field, list) {
return value;
}
+function validatePage(value, defaultValue = 1) {
+ return Math.max(1, Number.parseInt(value, 10) || defaultValue);
+}
+
+function validateLimit(value, defaultValue, maxLimit) {
+ const limit = Number.parseInt(value, 10) || defaultValue;
+ return Math.min(Math.max(1, limit), maxLimit);
+}
+
+function validateIsoDate(value, field) {
+ if (value === undefined || value === null || value === '') return new Date().toISOString();
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ throw new AppError(`${field} 格式无效`, 400);
+ }
+ return date.toISOString();
+}
+
module.exports = {
AppError,
getMonthRange,
@@ -94,5 +117,9 @@ module.exports = {
validateRequired,
validateNumber,
validateString,
- validateInList
+ optionalString,
+ validateInList,
+ validatePage,
+ validateLimit,
+ validateIsoDate
};