import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; const FORM_SLUG = 'shenmei-opinion-unified'; const ADMIN_PASSWORD = 'sm6666'; const MAX_RECORDS_PER_REQUEST = 50; const MAX_FIELD_LENGTH = 5000; const ALLOWED_FIELDS = [ 'id', 'name', 'goodPeople', 'goodBehaviors', 'badPeople', 'badSuggestions', 'time', 'timestamp', ]; function normalizeString(value) { return String(value ?? '').trim().slice(0, MAX_FIELD_LENGTH); } function normalizeRecord(raw) { const source = raw && typeof raw === 'object' ? raw : {}; const record = {}; for (const field of ALLOWED_FIELDS) { if (source[field] == null) continue; record[field] = field === 'timestamp' ? Number(source[field]) || Date.now() : normalizeString(source[field]); } if (!record.id) { record.id = `shenmei_${Date.now()}_${Math.random().toString(36).slice(2)}`; } if (!record.time) { record.time = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); } if (!record.timestamp) { record.timestamp = Date.now(); } return record; } function recordFingerprint(record) { const parts = ['name', 'goodPeople', 'goodBehaviors', 'badPeople', 'badSuggestions', 'time', 'timestamp'].map((key) => String(record?.[key] ?? '').trim().replace(/\s+/g, ' '), ); return parts.join('\u0000'); } function isValidRecord(record) { return Boolean(record.name && record.goodPeople && record.badPeople); } export async function readShenmeiOpinionEntries(filePath) { let text = ''; try { text = await fsPromises.readFile(filePath, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return []; throw error; } return text .split(/\n/) .filter(Boolean) .map((line) => JSON.parse(line)); } function publicRecordFromEntry(entry) { return entry?.record && typeof entry.record === 'object' ? entry.record : entry; } async function appendJsonLine(filePath, value) { await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); await fsPromises.appendFile(filePath, `${JSON.stringify(value)}\n`, 'utf8'); } function makeEnvelope(record, req) { return { form: FORM_SLUG, receivedAt: new Date().toISOString(), requestId: req.id ?? null, ipHash: req.ip ? String(req.ip) .split('') .reduce((hash, char) => ((hash << 5) - hash + char.charCodeAt(0)) | 0, 0) : null, userAgentHash: req.get('user-agent') ? String(req.get('user-agent')) .split('') .reduce((hash, char) => ((hash << 5) - hash + char.charCodeAt(0)) | 0, 0) : null, record, }; } export function createShenmeiOpinionFormService({ dataFile }) { if (!dataFile) throw new Error('dataFile is required'); async function listRecords() { const entries = await readShenmeiOpinionEntries(dataFile); return entries.map(publicRecordFromEntry).filter((record) => record && typeof record === 'object'); } async function submit(rawRecords, req) { const sourceRecords = Array.isArray(rawRecords) ? rawRecords : [rawRecords]; const normalized = sourceRecords.slice(0, MAX_RECORDS_PER_REQUEST).map(normalizeRecord).filter(isValidRecord); if (normalized.length === 0) return { inserted: 0, skipped: sourceRecords.length, total: (await listRecords()).length }; const existingEntries = await readShenmeiOpinionEntries(dataFile); const seen = new Set(existingEntries.map(publicRecordFromEntry).map(recordFingerprint)); let inserted = 0; let skipped = 0; for (const record of normalized) { const fingerprint = recordFingerprint(record); if (seen.has(fingerprint)) { skipped += 1; continue; } seen.add(fingerprint); await appendJsonLine(dataFile, makeEnvelope(record, req)); inserted += 1; } return { inserted, skipped, total: existingEntries.length + inserted, }; } return { dataFile, listRecords, submit, }; } export function attachShenmeiOpinionFormRoutes(api, options = {}) { const dataFile = options.dataFile ?? path.join(options.rootDir ?? process.cwd(), 'data', 'form-responses', `${FORM_SLUG}.ndjson`); const service = options.service ?? createShenmeiOpinionFormService({ dataFile }); api.post(`/public/forms/${FORM_SLUG}/submit`, async (req, res) => { try { const records = Array.isArray(req.body?.records) ? req.body.records : req.body?.record ?? req.body; const result = await service.submit(records, req); return res.json({ ok: true, ...result }); } catch (error) { console.error('[ShenmeiOpinionForm] submit failed:', error instanceof Error ? error.message : error); return res.status(500).json({ ok: false, message: '保存失败,请稍后重试' }); } }); api.get(`/public/forms/${FORM_SLUG}/responses`, async (req, res) => { if (req.get('x-shenmei-admin') !== ADMIN_PASSWORD) { return res.status(403).json({ ok: false, message: '后台密码错误' }); } try { const data = await service.listRecords(); return res.json({ ok: true, data }); } catch (error) { console.error('[ShenmeiOpinionForm] read failed:', error instanceof Error ? error.message : error); return res.status(500).json({ ok: false, message: '读取失败,请稍后重试' }); } }); return service; } export const shenmeiOpinionFormInternals = { FORM_SLUG, normalizeRecord, recordFingerprint, };