const express = require('express'); const { v4: uuidv4 } = require('uuid'); const { generateImage } = require('../lib/generate-image'); function createNoteRoutes(db) { const router = express.Router(); function createPublishTask(note) { // 已成功发布过的笔记不再创建新任务(防止重复发布) const completed = db.prepare( "SELECT id FROM publish_tasks WHERE note_id = ? AND status = 'completed'" ).get(note.id); if (completed) return; const existing = db.prepare( "SELECT id FROM publish_tasks WHERE note_id = ? AND status IN ('pending', 'claimed', 'executing')" ).get(note.id); if (existing) return; db.prepare( 'INSERT INTO publish_tasks (id, note_id, shop_id) VALUES (?, ?, ?)' ).run(uuidv4(), note.id, note.shop_id); } router.get('/next', (req, res) => { const { shop_id } = req.query; if (!shop_id) return res.status(400).json({ error: 'shop_id is required' }); const note = db.prepare( "SELECT n.*, p.title AS product_title, s.shop_name FROM notes n LEFT JOIN products p ON p.id = n.product_id LEFT JOIN shops s ON s.id = n.shop_id WHERE n.shop_id = ? AND n.status = 'approved' ORDER BY n.created_at ASC LIMIT 1" ).get(shop_id); if (!note) return res.status(404).json({ error: 'No pending notes' }); res.json({ ...note, topics: JSON.parse(note.topics || '[]'), image_paths: JSON.parse(note.image_paths || '[]'), }); }); router.get('/next/task', (req, res) => { req.url = '/next' + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''); return router.handle(req, res); }); router.get('/', (req, res) => { const { shop_id, status, product_id } = req.query; let sql = 'SELECT n.*, p.title as product_title FROM notes n LEFT JOIN products p ON p.id = n.product_id WHERE 1=1'; const params = []; if (shop_id) { sql += ' AND n.shop_id = ?'; params.push(shop_id); } if (status) { sql += ' AND n.status = ?'; params.push(status); } if (product_id) { sql += ' AND n.product_id = ?'; params.push(product_id); } sql += ' ORDER BY n.created_at DESC'; const notes = db.prepare(sql).all(...params); const parsed = notes.map(n => ({ ...n, topics: JSON.parse(n.topics || '[]'), image_paths: JSON.parse(n.image_paths || '[]'), })); res.json(parsed); }); router.get('/export', (req, res) => { const { shop_id, status } = req.query; let sql = 'SELECT n.*, s.shop_name, p.title as product_title FROM notes n LEFT JOIN shops s ON s.id = n.shop_id LEFT JOIN products p ON p.id = n.product_id WHERE 1=1'; const params = []; if (shop_id) { sql += ' AND n.shop_id = ?'; params.push(shop_id); } if (status) { sql += ' AND n.status = ?'; params.push(status); } sql += ' ORDER BY n.created_at DESC'; const notes = db.prepare(sql).all(...params); const parsed = notes.map(n => ({ ...n, topics: JSON.parse(n.topics || '[]'), image_paths: JSON.parse(n.image_paths || '[]'), })); res.json(parsed); }); router.get('/:id', (req, res) => { const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id); if (!note) return res.status(404).json({ error: 'Note not found' }); res.json({ ...note, topics: JSON.parse(note.topics || '[]'), image_paths: JSON.parse(note.image_paths || '[]'), }); }); router.post('/', (req, res) => { const { product_id, shop_id, title, content, topics, image_paths } = req.body; if (!product_id || !shop_id || !title) { return res.status(400).json({ error: 'product_id, shop_id, and title are required' }); } const id = uuidv4(); db.prepare( 'INSERT INTO notes (id, product_id, shop_id, title, content, topics, image_paths) VALUES (?, ?, ?, ?, ?, ?, ?)' ).run(id, product_id, shop_id, title, content || '', JSON.stringify(topics || []), JSON.stringify(image_paths || [])); res.status(201).json(db.prepare('SELECT * FROM notes WHERE id = ?').get(id)); }); // Batch delete notes (POST because DELETE with body is not reliable) router.post('/batch-delete', (req, res) => { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: 'ids array required' }); } const placeholders = ids.map(() => '?').join(','); const result = db.prepare('DELETE FROM notes WHERE id IN (' + placeholders + ')').run(...ids); res.json({ deleted: result.changes }); }); // Batch delete notes (legacy DELETE route) router.delete('/batch', (req, res) => { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: 'ids array required' }); } const placeholders = ids.map(() => '?').join(','); const result = db.prepare('DELETE FROM notes WHERE id IN (' + placeholders + ')').run(...ids); res.json({ deleted: result.changes }); }); router.post('/batch/approve', (req, res) => { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: 'ids array required' }); } const placeholders = ids.map(() => '?').join(','); db.prepare('UPDATE notes SET status = ?, use_status = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (' + placeholders + ')').run('approved', 'pending', ...ids); const batchNotes = db.prepare('SELECT * FROM notes WHERE id IN (' + placeholders + ')').all(...ids); for (const note of batchNotes) { createPublishTask(note); } res.json({ updated: ids.length }); }); router.post('/batch/reject', (req, res) => { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: 'ids array required' }); } const placeholders = ids.map(() => '?').join(','); db.prepare('UPDATE notes SET status = ?, use_status = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (' + placeholders + ')').run('pending', 'pending', ...ids); res.json({ updated: ids.length }); }); router.post('/batch/publish-reset', (req, res) => { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: 'ids array required' }); } const placeholders = ids.map(() => '?').join(','); const resetNotes = db.prepare( 'SELECT * FROM notes WHERE id IN (' + placeholders + ") AND status = 'approved' AND use_status = 'failed'" ).all(...ids); const reset = db.transaction(() => { const result = db.prepare( 'UPDATE notes SET use_status = ?, error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (' + placeholders + ") AND status = 'approved' AND use_status = 'failed'" ).run('pending', '', ...ids); for (const note of resetNotes) { createPublishTask({ ...note, use_status: 'pending' }); } return result.changes; }); res.json({ updated: reset() }); }); router.put('/:id', (req, res) => { const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id); if (!note) return res.status(404).json({ error: 'Note not found' }); const { title, content, topics, image_paths, status, image_prompt, worker_id } = req.body; const nextStatus = status || note.status; const nextUseStatus = nextStatus === 'approved' && note.status !== 'approved' ? 'pending' : note.use_status; db.prepare(`UPDATE notes SET title=?, content=?, topics=?, image_paths=?, status=?, use_status=?, image_prompt=?, worker_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`) .run( title || note.title, content !== undefined ? content : note.content, topics ? JSON.stringify(topics) : note.topics, image_paths ? JSON.stringify(image_paths) : note.image_paths, nextStatus, nextUseStatus, image_prompt !== undefined ? image_prompt : note.image_prompt, worker_id !== undefined ? worker_id : note.worker_id, req.params.id ); if (status === 'approved' && note.status !== 'approved') { createPublishTask({ ...note, status: 'approved' }); } res.json(db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id)); }); router.post('/:id/rewrite', async (req, res) => { const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id); if (!note) return res.status(404).json({ error: 'Note not found' }); const aiConfig = {}; for (const k of ['ai_api_key', 'ai_model', 'ai_base_url', 'ai_temperature', 'prompt_template']) { const row = db.prepare('SELECT value FROM configs WHERE key = ?').get(k); aiConfig[k] = row?.value || ''; } if (!aiConfig.ai_api_key) { return res.status(400).json({ error: 'AI API key not configured' }); } const product = db.prepare('SELECT * FROM products WHERE id = ?').get(note.product_id); const productTitle = product?.title || note.title; db.prepare("UPDATE notes SET status='generating', use_status='pending', updated_at=CURRENT_TIMESTAMP WHERE id=?").run(req.params.id); // Cancel any pending publish tasks for this note db.prepare("UPDATE publish_tasks SET status='failed', updated_at=CURRENT_TIMESTAMP WHERE note_id = ? AND status IN ('pending', 'claimed', 'executing')").run(req.params.id); try { const promptTemplate = aiConfig.prompt_template || aiConfig.content_prompt_template || '为商品写一篇小红书笔记:{{title}}'; const prompt = promptTemplate.replace(/\{\{title\}\}/g, productTitle).replace(/\{title\}/g, productTitle); const temperature = parseFloat(aiConfig.ai_temperature || '0.8'); const response = await fetch((aiConfig.ai_base_url || 'https://api.deepseek.com') + '/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + aiConfig.ai_api_key }, body: JSON.stringify({ model: aiConfig.ai_model || 'deepseek-chat', messages: [{ role: 'user', content: prompt }], temperature, max_tokens: 2000, }), }); if (!response.ok) throw new Error('AI API error: ' + response.status); const data = await response.json(); const generated = data.choices?.[0]?.message?.content || ''; const lines = generated.split('\n').filter(l => l.trim()); const titleLine = lines.find(l => l.startsWith('#')) || lines[0] || productTitle; const title = titleLine.replace(/^#+\s*/, '').trim(); const content = lines.slice(lines.indexOf(titleLine) + 1).join('\n').trim() || generated; const topicMatches = content.match(/#[^\s#]+/g) || []; const topics = topicMatches.map(t => t.replace('#', '').trim()); // Generate new images const imageResult = await generateImage(db, req.params.id, productTitle); const imagePaths = imageResult.imagePaths || []; const imagePrompt = imageResult.imagePrompt || ''; db.prepare("UPDATE notes SET title=?, content=?, topics=?, image_paths=?, image_prompt=?, status='generated', updated_at=CURRENT_TIMESTAMP WHERE id=?") .run(title, content, JSON.stringify(topics), JSON.stringify(imagePaths), imagePrompt, req.params.id); res.json({ id: req.params.id, title, content, topics, image_paths: imagePaths }); } catch (error) { db.prepare("UPDATE notes SET status='failed', error_message=?, updated_at=CURRENT_TIMESTAMP WHERE id=?") .run(error.message, req.params.id); res.status(500).json({ error: error.message }); } }); router.post('/:id/complete', (req, res) => { const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id); if (!note) return res.status(404).json({ error: 'Note not found' }); db.prepare("UPDATE notes SET status='approved', use_status='used', publish_result=?, error_message='', published_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?") .run(JSON.stringify(req.body.result || {}), req.params.id); db.prepare("UPDATE publish_tasks SET status='completed', updated_at=CURRENT_TIMESTAMP WHERE note_id=? AND status != 'completed'") .run(req.params.id); res.json({ success: true }); }); router.post('/:id/fail', (req, res) => { const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id); if (!note) return res.status(404).json({ error: 'Note not found' }); const { error_message } = req.body; db.prepare("UPDATE notes SET status='approved', use_status='failed', error_message=?, updated_at=CURRENT_TIMESTAMP WHERE id=?") .run(error_message || 'Unknown error', req.params.id); db.prepare("UPDATE publish_tasks SET status='failed', error_message=?, updated_at=CURRENT_TIMESTAMP WHERE note_id=? AND status != 'completed'") .run(error_message || 'Unknown error', req.params.id); res.json({ success: true }); }); router.post('/batch', (req, res) => { const { product_id, shop_id, notes } = req.body; if (!product_id || !shop_id || !Array.isArray(notes)) { return res.status(400).json({ error: 'product_id, shop_id, and notes array required' }); } const insert = db.prepare( 'INSERT INTO notes (id, product_id, shop_id, title, content, topics, image_paths) VALUES (?, ?, ?, ?, ?, ?, ?)' ); const ids = []; const batch = db.transaction(() => { for (const n of notes) { const id = uuidv4(); insert.run(id, product_id, shop_id, n.title, n.content || '', JSON.stringify(n.topics || []), JSON.stringify(n.image_paths || [])); ids.push(id); } }); batch(); res.status(201).json({ count: ids.length, ids }); }); router.delete('/:id', (req, res) => { const result = db.prepare('DELETE FROM notes WHERE id = ?').run(req.params.id); if (result.changes === 0) return res.status(404).json({ error: 'Note not found' }); res.json({ success: true }); }); return router; } module.exports = createNoteRoutes;