feat: 新增短链接工具,优化日历提醒功能
1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持 2. 新增移动端日历提醒工具与鉴权配置 3. 优化日历事件编辑与展示: - 添加事件循环重复设置 - 新增全天事件支持 - 时间选择器自动对齐15分钟间隔 - 保存状态加载动画 - 事件卡片显示重复规则 4. 更新API基础地址与路由配置 5. 新增SSL证书文件与相关工具静态资源
This commit is contained in:
+146
-1
@@ -78,6 +78,7 @@ const plantHome = require('./plant_home')
|
||||
const globalNews = require('./global_news')
|
||||
const boxBackup = require('./box_backup')
|
||||
const yuanzhupai = require('./yuanzhupai')
|
||||
const shortLink = require('./short_link')
|
||||
const dataGateway = require('./data_gateway')
|
||||
const ossFileCabinet = require('./oss_file_cabinet')
|
||||
yuanzhupai.initDb()
|
||||
@@ -1579,7 +1580,9 @@ const mapSystemIdToUrl = (systemId) => {
|
||||
'Tools-plant_home': '/tools/plant_home',
|
||||
'Tools-apple_watch': '/tools/apple_watch',
|
||||
'Tools-private_clipboard': '/tools/private_clipboard',
|
||||
'Tools-style_check': '/tools/style_check/index.html'
|
||||
'Tools-style_check': '/tools/style_check/index.html',
|
||||
'Tools-short_link': '/tools/short_link',
|
||||
'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder'
|
||||
}
|
||||
if (m[systemId]) return m[systemId]
|
||||
if (systemId.startsWith('Tools-')) {
|
||||
@@ -5721,6 +5724,133 @@ app.use('/tools/yuanzhupai', (req, res, next) => {
|
||||
return res.status(401).send('未授权')
|
||||
})
|
||||
|
||||
// Short Link: 鉴权基础函数 + 路由守卫(必须在 express.static 之前)
|
||||
const SHORT_LINK_AUTH_COOKIE = 'short_link_gate'
|
||||
const readShortLinkAuthConfig = () => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'public', 'tools', 'short_link', 'auth_config.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
const readShortLinkJwk = () => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'config', 'short_link.jwk.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return { kty: 'RSA', n: '', e: '' }
|
||||
}
|
||||
const getShortLinkCookieName = () => {
|
||||
try { const n = String(readShortLinkAuthConfig().cookieName || ''); if (n) return n } catch {}
|
||||
return SHORT_LINK_AUTH_COOKIE
|
||||
}
|
||||
const getShortLinkMaxAge = () => {
|
||||
try { const days = Number(readShortLinkAuthConfig().max_age_days || 0); if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000 } catch {}
|
||||
return 30 * 24 * 3600 * 1000
|
||||
}
|
||||
const getShortLinkGuidConfig = () => {
|
||||
try {
|
||||
const cfg = readShortLinkAuthConfig()
|
||||
const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
|
||||
const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
|
||||
return { key: keyRaw || 'guid', value: valueRaw }
|
||||
} catch { return { key: 'guid', value: '' } }
|
||||
}
|
||||
const hasValidShortLinkGuid = req => {
|
||||
try {
|
||||
const cfg = readShortLinkAuthConfig()
|
||||
if (cfg.enable_auth === false) return false
|
||||
const { key, value } = getShortLinkGuidConfig()
|
||||
if (!value) return false
|
||||
const q = req && req.query ? req.query : {}
|
||||
const raw = q[key]
|
||||
if (raw === undefined || raw === null) return false
|
||||
const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
|
||||
return incoming === value
|
||||
} catch { return false }
|
||||
}
|
||||
const isShortLinkForceGuid = () => {
|
||||
try {
|
||||
const cfg = readShortLinkAuthConfig()
|
||||
if (cfg.enable_auth === false) return false
|
||||
if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
|
||||
if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
|
||||
return false
|
||||
} catch { return false }
|
||||
}
|
||||
const hasShortLinkAuth = req => {
|
||||
try {
|
||||
const config = readShortLinkAuthConfig()
|
||||
if (config.enable_auth === false) return true
|
||||
const cookies = parseCookie(req.headers.cookie || '')
|
||||
return cookies[getShortLinkCookieName()] === '1'
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
// 第1层:精确入口守卫
|
||||
app.get('/tools/short_link', (req, res, next) => {
|
||||
setNoCache(res)
|
||||
try {
|
||||
const forceGuid = isShortLinkForceGuid()
|
||||
if (hasShortLinkAuth(req)) return next()
|
||||
const guidKey = getShortLinkGuidConfig().key
|
||||
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||||
if (hasValidShortLinkGuid(req)) {
|
||||
const maxAge = getShortLinkMaxAge()
|
||||
const cookieName = getShortLinkCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/short_link/index.html')
|
||||
}
|
||||
if (hasGuidInput) return res.status(401).send('未授权')
|
||||
const token = String(req.query.token || '')
|
||||
if (!token) {
|
||||
if (forceGuid) return res.status(401).send('未授权')
|
||||
return next()
|
||||
}
|
||||
const payload = verifyJwtWithKeys(token, jwkKeys(readShortLinkJwk()))
|
||||
if (!payload) return res.status(401).send('未授权')
|
||||
{
|
||||
const iss = String(payload.iss || '')
|
||||
const navIss = getNavIssFromFlags()
|
||||
if (iss === navIss && !audMatch(payload, 'Tools-short_link')) return res.status(401).send('未授权')
|
||||
}
|
||||
const maxAge = getShortLinkMaxAge()
|
||||
const cookieName = getShortLinkCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/short_link/index.html')
|
||||
} catch (e) { return res.status(401).send('未授权') }
|
||||
})
|
||||
|
||||
// 第2层:子路径守卫(拦截静态资源绕过)
|
||||
app.use('/tools/short_link', (req, res, next) => {
|
||||
const forceGuid = isShortLinkForceGuid()
|
||||
if (hasShortLinkAuth(req)) return next()
|
||||
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||||
const guidKey = getShortLinkGuidConfig().key
|
||||
const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||||
if (isHandshake && hasValidShortLinkGuid(req)) {
|
||||
const maxAge = getShortLinkMaxAge()
|
||||
const cookieName = getShortLinkCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/short_link/index.html')
|
||||
}
|
||||
if (hasGuidInput) return res.status(401).send('未授权')
|
||||
if (isHandshake && String(req.query.token || '')) {
|
||||
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readShortLinkJwk()))
|
||||
if (payload) {
|
||||
const iss = String(payload.iss || '')
|
||||
const navIss = getNavIssFromFlags()
|
||||
if (iss === navIss && !audMatch(payload, 'Tools-short_link')) return res.status(401).send('未授权')
|
||||
const maxAge = getShortLinkMaxAge()
|
||||
const cookieName = getShortLinkCookieName()
|
||||
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||
return res.redirect('/tools/short_link/index.html')
|
||||
}
|
||||
}
|
||||
if (isHandshake && forceGuid) return res.status(401).send('未授权')
|
||||
return res.status(401).send('未授权')
|
||||
})
|
||||
|
||||
const BOX_BACKUP_AUTH_COOKIE = 'box_disaster_sentinel_gate'
|
||||
const readBoxBackupAuthConfig = () => {
|
||||
try {
|
||||
@@ -6395,6 +6525,21 @@ globalNews.bindRoutes(app)
|
||||
dataGateway.bindRoutes(app)
|
||||
ossFileCabinet.bindRoutes(app)
|
||||
|
||||
// 第3层:Short Link API 鉴权(必须在 bindRoutes 之前注册)
|
||||
app.use('/api/short_link', (req, res, next) => {
|
||||
if (req.path === '/auth/hint') return next()
|
||||
if (hasShortLinkAuth(req)) return next()
|
||||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||
})
|
||||
|
||||
app.get('/api/short_link/auth/hint', (req, res) => {
|
||||
setNoCache(res)
|
||||
res.json({ ok: true, message: '请通过首页九宫格点击「短链接」进入,或使用正确的访问链接' })
|
||||
})
|
||||
|
||||
// Short Link 业务路由(/s/:code 公开跳转 + /api/short_link/* 管理接口)
|
||||
shortLink.bindRoutes(app)
|
||||
|
||||
const marketsCfgAtBoot = getMarketsCfg()
|
||||
const currentPort = Number(process.env.PORT || '8976') || 8976
|
||||
const marketsAutomationPort = Number(marketsCfgAtBoot.automation_port || 8977) || 8977
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
const Database = require('better-sqlite3')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const crypto = require('crypto')
|
||||
|
||||
// 数据目录
|
||||
const dataDir = path.join(process.cwd(), 'data')
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
|
||||
|
||||
const db = new Database(path.join(dataDir, 'short_link.db'))
|
||||
db.pragma('journal_mode = WAL')
|
||||
|
||||
// 建表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS short_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
target_url TEXT NOT NULL,
|
||||
title TEXT DEFAULT '',
|
||||
source_type TEXT DEFAULT 'manual',
|
||||
source_ref TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
max_clicks INTEGER,
|
||||
click_count INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_by TEXT DEFAULT 'admin'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS short_link_clicks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL,
|
||||
clicked_at TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
referer TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sl_clicks_code ON short_link_clicks(code);
|
||||
`)
|
||||
|
||||
// 配置读取
|
||||
const readConfig = () => {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'config', 'short_link.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
// 短码生成(去除易混淆字符 0O1lI)
|
||||
const SAFE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
|
||||
const generateCode = (len = 6) => {
|
||||
const bytes = crypto.randomBytes(len)
|
||||
let code = ''
|
||||
for (let i = 0; i < len; i++) {
|
||||
code += SAFE_CHARS[bytes[i] % SAFE_CHARS.length]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
const generateUniqueCode = (len = 6) => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const code = generateCode(len)
|
||||
const exists = db.prepare('SELECT 1 FROM short_links WHERE code = ?').get(code)
|
||||
if (!exists) return code
|
||||
}
|
||||
return generateCode(len + 2)
|
||||
}
|
||||
|
||||
// 北京时间
|
||||
const nowBJ = () => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||
}).format(new Date()).replace(/\//g, '-').replace(/\u200E/g, '')
|
||||
} catch {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 解析短码 → 目标 URL
|
||||
const resolveCode = (code) => {
|
||||
// 1. 先查 tool 别名(配置级)
|
||||
const cfg = readConfig()
|
||||
const aliases = cfg.tool_aliases || {}
|
||||
if (aliases[code]) {
|
||||
return { target: aliases[code], type: 'tool_alias', title: code }
|
||||
}
|
||||
// 2. 查数据库
|
||||
const row = db.prepare('SELECT * FROM short_links WHERE code = ? AND is_active = 1').get(code)
|
||||
if (!row) return null
|
||||
if (row.expires_at && row.expires_at < nowBJ()) return { expired: true }
|
||||
if (row.max_clicks && row.click_count >= row.max_clicks) return { exhausted: true }
|
||||
return { target: row.target_url, type: row.source_type, title: row.title, row }
|
||||
}
|
||||
|
||||
// 记录点击
|
||||
const recordClick = (code, ip, userAgent, referer) => {
|
||||
try {
|
||||
db.prepare('INSERT INTO short_link_clicks (code, clicked_at, ip, user_agent, referer) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(code, nowBJ(), ip || '', userAgent || '', referer || '')
|
||||
db.prepare('UPDATE short_links SET click_count = click_count + 1 WHERE code = ?').run(code)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// CRUD
|
||||
const createLink = ({ target_url, title, code, source_type, source_ref, expires_at, max_clicks }) => {
|
||||
const finalCode = code || generateUniqueCode()
|
||||
const exists = db.prepare('SELECT 1 FROM short_links WHERE code = ?').get(finalCode)
|
||||
if (exists) throw new Error(`短码 "${finalCode}" 已存在`)
|
||||
const cfg = readConfig()
|
||||
const aliases = cfg.tool_aliases || {}
|
||||
if (aliases[finalCode]) throw new Error(`短码 "${finalCode}" 与工具别名冲突`)
|
||||
db.prepare(`INSERT INTO short_links (code, target_url, title, source_type, source_ref, created_at, expires_at, max_clicks) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(finalCode, target_url, title || '', source_type || 'manual', source_ref || '', nowBJ(), expires_at || null, max_clicks || null)
|
||||
return db.prepare('SELECT * FROM short_links WHERE code = ?').get(finalCode)
|
||||
}
|
||||
|
||||
const listLinks = (page = 1, pageSize = 50, keyword = '') => {
|
||||
const offset = (page - 1) * pageSize
|
||||
let where = 'WHERE 1=1'
|
||||
const params = []
|
||||
if (keyword) {
|
||||
where += ' AND (code LIKE ? OR target_url LIKE ? OR title LIKE ?)'
|
||||
const kw = `%${keyword}%`
|
||||
params.push(kw, kw, kw)
|
||||
}
|
||||
const total = db.prepare(`SELECT COUNT(*) as cnt FROM short_links ${where}`).get(...params).cnt
|
||||
const rows = db.prepare(`SELECT * FROM short_links ${where} ORDER BY id DESC LIMIT ? OFFSET ?`).all(...params, pageSize, offset)
|
||||
return { total, rows, page, pageSize }
|
||||
}
|
||||
|
||||
const updateLink = (code, { target_url, title, expires_at, max_clicks, is_active }) => {
|
||||
const existing = db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
|
||||
if (!existing) throw new Error('短链接不存在')
|
||||
const fields = []
|
||||
const params = []
|
||||
if (target_url !== undefined) { fields.push('target_url = ?'); params.push(target_url) }
|
||||
if (title !== undefined) { fields.push('title = ?'); params.push(title) }
|
||||
if (expires_at !== undefined) { fields.push('expires_at = ?'); params.push(expires_at || null) }
|
||||
if (max_clicks !== undefined) { fields.push('max_clicks = ?'); params.push(max_clicks || null) }
|
||||
if (is_active !== undefined) { fields.push('is_active = ?'); params.push(is_active ? 1 : 0) }
|
||||
if (fields.length === 0) return existing
|
||||
params.push(code)
|
||||
db.prepare(`UPDATE short_links SET ${fields.join(', ')} WHERE code = ?`).run(...params)
|
||||
return db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
|
||||
}
|
||||
|
||||
const deleteLink = (code) => {
|
||||
const existing = db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
|
||||
if (!existing) throw new Error('短链接不存在')
|
||||
db.prepare('DELETE FROM short_link_clicks WHERE code = ?').run(code)
|
||||
db.prepare('DELETE FROM short_links WHERE code = ?').run(code)
|
||||
return existing
|
||||
}
|
||||
|
||||
const getStats = (code, limit = 100) => {
|
||||
const link = db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
|
||||
if (!link) throw new Error('短链接不存在')
|
||||
const clicks = db.prepare('SELECT * FROM short_link_clicks WHERE code = ? ORDER BY id DESC LIMIT ?').all(code, limit)
|
||||
return { link, clicks }
|
||||
}
|
||||
|
||||
const batchCreate = (items) => {
|
||||
const results = []
|
||||
for (const item of items) {
|
||||
try {
|
||||
results.push({ ok: true, data: createLink(item) })
|
||||
} catch (e) {
|
||||
results.push({ ok: false, error: String(e.message || e), target_url: item.target_url })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
const bindRoutes = (app) => {
|
||||
// ===== 公开跳转路由(无需鉴权)=====
|
||||
app.get('/l/:code', (req, res) => {
|
||||
try {
|
||||
const code = String(req.params.code || '').trim()
|
||||
if (!code) return res.status(400).send('Bad Request')
|
||||
const result = resolveCode(code)
|
||||
if (!result) return res.status(404).send('链接不存在或已失效')
|
||||
if (result.expired) return res.status(410).send('链接已过期')
|
||||
if (result.exhausted) return res.status(410).send('链接已达最大访问次数')
|
||||
if (result.type !== 'tool_alias') {
|
||||
recordClick(code, req.ip, req.headers['user-agent'], req.headers['referer'])
|
||||
}
|
||||
const target = result.target
|
||||
if (target.startsWith('/')) {
|
||||
const proto = req.headers['x-forwarded-proto'] || req.protocol
|
||||
const host = req.headers['host'] || 'localhost:8976'
|
||||
return res.redirect(302, `${proto}://${host}${target}`)
|
||||
}
|
||||
return res.redirect(302, target)
|
||||
} catch (e) {
|
||||
res.status(500).send('Server Error')
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 管理 API(鉴权由 index.js 的 app.use('/api/short_link') 处理)=====
|
||||
|
||||
// 获取配置
|
||||
app.get('/api/short_link/config', (req, res) => {
|
||||
try {
|
||||
const cfg = readConfig()
|
||||
res.json({
|
||||
ok: true,
|
||||
domain: cfg.domain || '',
|
||||
tool_aliases: cfg.tool_aliases || {},
|
||||
ui_mode: cfg.ui_mode || 'debug'
|
||||
})
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
// 列表
|
||||
app.get('/api/short_link/list', (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, Number(req.query.page) || 1)
|
||||
const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize) || 50))
|
||||
const keyword = String(req.query.keyword || '').trim()
|
||||
const data = listLinks(page, pageSize, keyword)
|
||||
res.json({ ok: true, ...data })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建
|
||||
app.post('/api/short_link/create', (req, res) => {
|
||||
try {
|
||||
const body = req.body || {}
|
||||
const target_url = String(body.target_url || '').trim()
|
||||
if (!target_url) return res.status(400).json({ ok: false, error: '目标链接不能为空' })
|
||||
const data = createLink({
|
||||
target_url,
|
||||
title: String(body.title || '').trim(),
|
||||
code: String(body.code || '').trim() || undefined,
|
||||
source_type: String(body.source_type || 'manual').trim(),
|
||||
source_ref: String(body.source_ref || '').trim(),
|
||||
expires_at: body.expires_at || null,
|
||||
max_clicks: body.max_clicks ? Number(body.max_clicks) : null
|
||||
})
|
||||
res.json({ ok: true, data })
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
// 编辑
|
||||
app.put('/api/short_link/:code', (req, res) => {
|
||||
try {
|
||||
const code = String(req.params.code || '').trim()
|
||||
const data = updateLink(code, req.body || {})
|
||||
res.json({ ok: true, data })
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除
|
||||
app.delete('/api/short_link/:code', (req, res) => {
|
||||
try {
|
||||
const code = String(req.params.code || '').trim()
|
||||
const data = deleteLink(code)
|
||||
res.json({ ok: true, data })
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
// 统计
|
||||
app.get('/api/short_link/stats/:code', (req, res) => {
|
||||
try {
|
||||
const code = String(req.params.code || '').trim()
|
||||
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100))
|
||||
const data = getStats(code, limit)
|
||||
res.json({ ok: true, ...data })
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
|
||||
// 批量创建
|
||||
app.post('/api/short_link/batch', (req, res) => {
|
||||
try {
|
||||
const items = req.body && req.body.items
|
||||
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ ok: false, error: '请提供 items 数组' })
|
||||
if (items.length > 100) return res.status(400).json({ ok: false, error: '单次最多 100 条' })
|
||||
const results = batchCreate(items)
|
||||
res.json({ ok: true, results })
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { bindRoutes, readConfig }
|
||||
Reference in New Issue
Block a user