xiaohongshufabu/server/routes/notes.js

302 lines
14 KiB
JavaScript

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;