Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
34 KiB
JSON

{"repo": "celestebz/kol-campaign-os", "files": {"server/routes/kolStrategies.js": {"24984f1b078818b2b83ce91e5ffa9603ddcf37a5": {"t": 1786067877.8606462, "n": 33614, "h": "70481ad982a34336", "c": "const express = require('express');\nconst fs = require('fs');\nconst path = require('path');\nconst multer = require('multer');\nconst mammoth = require('mammoth');\nconst pdfParse = require('pdf-parse');\nconst { dbOperations, sequelize, Sequelize } = require('../database');\n\nconst router = express.Router();\n\nconst SYSTEM_SELECTION_KEY = 'system.provider_selection';\nconst MAX_MATERIAL_CHARS = 50000;\nconst MAX_MATERIAL_FILES = 5;\nconst MAX_FILE_SIZE = 10 * 1024 * 1024;\nconst MYSQL_SIGNED_INT_MAX = 2147483647;\n\nfunction getDataDir() {\n if (process.pkg) return path.join(path.dirname(process.execPath), 'data');\n return path.join(__dirname, '..', '..', 'data');\n}\n\nconst materialUploadsDir = path.join(getDataDir(), 'uploads', 'strategy-materials');\nif (!fs.existsSync(materialUploadsDir)) fs.mkdirSync(materialUploadsDir, { recursive: true });\n\nconst upload = multer({\n storage: multer.diskStorage({\n destination: (req, file, cb) => cb(null, materialUploadsDir),\n filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`)\n }),\n limits: { fileSize: MAX_FILE_SIZE, files: MAX_MATERIAL_FILES },\n fileFilter: (req, file, cb) => {\n const ext = path.extname(file.originalname).toLowerCase();\n if (['.txt', '.pdf', '.docx'].includes(ext)) cb(null, true);\n else cb(new Error('Only TXT, PDF, and DOCX files are supported'));\n }\n});\n\nconst DEFAULT_SCORING_WEIGHTS = {\n content_relevance: 25,\n audience_market_fit: 20,\n content_quality: 15,\n engagement_quality: 15,\n commercial_collaboration_fit: 10,\n conversion_potential: 15,\n risk_deduction_max: 10,\n approval_threshold: 55,\n hero_threshold: 85,\n mid_tier_threshold: 75,\n micro_threshold: 65\n};\n\nfunction clean(value) {\n if (value === undefined || value === null) return '';\n return String(value).trim();\n}\n\nfunction parseJson(value, fallback = {}) {\n if (!value) return fallback;\n if (typeof value === 'object') return value;\n try {\n return JSON.parse(value);\n } catch (error) {\n return fallback;\n }\n}\n\nfunction asJson(value, fallback) {\n if (value === undefined || value === null || value === '') return JSON.stringify(fallback);\n if (typeof value === 'string') return value;\n return JSON.stringify(value);\n}\n\nfunction hasOwn(object, key) {\n return Object.prototype.hasOwnProperty.call(object, key);\n}\n\nfunction parsePathId(value) {\n if (typeof value !== 'string' || !/^[1-9]\\d*$/.test(value)) return null;\n const parsed = Number(value);\n return Number.isSafeInteger(parsed) && parsed <= MYSQL_SIGNED_INT_MAX ? parsed : null;\n}\n\nfunction parseBodyId(value) {\n return typeof value === 'number'\n && Number.isSafeInteger(value)\n && value > 0\n && value <= MYSQL_SIGNED_INT_MAX\n ? value\n : null;\n}\n\nfunction routeError(message, statusCode) {\n const error = new Error(message);\n error.statusCode = statusCode;\n return error;\n}\n\nfunction isConcurrencyError(error) {\n const code = error?.original?.code || error?.parent?.code || error?.code;\n return error?.name === 'SequelizeForeignKeyConstraintError'\n || error?.name === 'SequelizeUniqueConstraintError'\n || error?.name === 'SequelizeTimeoutError'\n || ['ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT', 'ER_NO_REFERENCED_ROW_2', 'ER_ROW_IS_REFERENCED_2'].includes(code);\n}\n\nfunction sendRouteError(res, error, fallbackStatus = 500) {\n const status = error.statusCode || (isConcurrencyError(error) ? 409 : fallbackStatus);\n return res.status(status).json({ success: false, error: error.message });\n}\n\nasync function transactionGet(sql, params, transaction) {\n const rows = await sequelize.query(sql, {\n replacements: params,\n type: Sequelize.QueryTypes.SELECT,\n transaction,\n logging: false\n });\n return rows[0] || null;\n}\n\nasync function transactionRun(sql, params, transaction) {\n const [result, metadata] = await sequelize.query(sql, {\n replacements: params,\n type: Sequelize.QueryTypes.RAW,\n transaction,\n logging: false\n });\n const isInsert = /^\\s*INSERT\\b/i.test(sql);\n return {\n id: isInsert ? (Number(result) || 0) : 0,\n changes: Number(metadata !== undefined ? metadata : result) || 0\n };\n}\n\nfunction rejectLegacyCycleFields(body = {}) {\n const legacy = ['search_strategy', 'cycles', 'search_cycles', 'search_intensity']\n .filter((key) => Object.prototype.hasOwnProperty.call(body, key));\n if (legacy.length) {\n const error = new Error(`Legacy Cycle fields are no longer supported: ${legacy.join(', ')}`);\n error.statusCode = 400;\n throw error;\n }\n}\n\nfunction normalizeStrategy(row) {\n if (!row) return row;\n const { joined_campaign_product_id: joinedCampaignProductId, ...strategy } = row;\n return {\n ...strategy,\n product_binding_status: row.campaign_product_id == null\n ? 'legacy_unassigned'\n : joinedCampaignProductId == null\n ? 'invalid_binding'\n : 'assigned',\n secondary_platforms: parseJson(row.secondary_platforms, []),\n product_context: parseJson(row.product_context, {}),\n persona_config: parseJson(row.persona_config, {}),\n scoring_weights: parseJson(row.scoring_weights, DEFAULT_SCORING_WEIGHTS),\n finder_handoff: parseJson(row.finder_handoff, {}),\n source_material_meta: parseJson(row.source_material_meta, {}),\n research_sources: parseJson(row.research_sources, [])\n };\n}\n\nasync function getCampaignProductForStrategy(\n campaignId,\n campaignProductId,\n { requireActive = false, transaction = null } = {}\n) {\n if (parseBodyId(campaignProductId) === null) {\n throw routeError('campaign_product_id is required and must be a positive integer', 400);\n }\n\n const sql =\n `SELECT cp.id, cp.campaign_id, cp.product_id, cp.status, p.name AS product_name\n FROM campaign_products cp\n JOIN products p ON p.id = cp.product_id\n WHERE cp.id = ?${transaction ? ' FOR UPDATE' : ''}`;\n const campaignProduct = transaction\n ? await transactionGet(sql, [campaignProductId], transaction)\n : await dbOperations.get(sql, [campaignProductId]);\n if (!campaignProduct) {\n throw routeError('Campaign Product not found', 400);\n }\n if (campaignProduct.campaign_id !== campaignId) {\n throw routeError('Campaign Product 不属于当前项目', 400);\n }\n if (requireActive && campaignProduct.status !== 'active') {\n throw routeError('Campaign Product must be active', 400);\n }\n return campaignProduct;\n}\n\nfunction extractJson(content) {\n const raw = String(content || '').trim();\n const stripped = raw.replace(/^```(?:json)?\\s*/i, '').replace(/```\\s*$/i, '').trim();\n const candidates = [stripped];\n const start = stripped.indexOf('{');\n const end = stripped.lastIndexOf('}');\n if (start >= 0 && end > start) candidates.push(stripped.slice(start, end + 1));\n for (const candidate of candidates) {\n try {\n return JSON.parse(candidate);\n } catch (error) {\n // try next\n }\n }\n throw new Error('AI did not return valid JSON');\n}\n\nfunction readTextFileIfExists(filePath) {\n try {\n return fs.readFileSync(filePath, 'utf8');\n } catch (error) {\n return '';\n }\n}\n\nfunction loadKolStrategySkillContext() {\n const skillRoot = path.join(__dirname, '..', '..', 'skills', 'kol-campaign-os-agent');\n const skill = readTextFileIfExists(path.join(skillRoot, 'references', 'strategy.md'));\n const schema = readTextFileIfExists(path.join(skillRoot, 'references', 'strategy-output-schema.md'));\n return [skill, schema].filter(Boolean).join('\\n\\n---\\n\\n');\n}\n\nasync function extractMaterialFileText(file) {\n const ext = path.extname(file.originalname).toLowerCase();\n if (ext === '.txt') {\n return fs.readFileSync(file.path, 'utf8');\n }\n if (ext === '.docx') {\n const result = await mammoth.extractRawText({ path: file.path });\n return result.value || '';\n }\n if (ext === '.pdf') {\n const buffer = fs.readFileSync(file.path);\n const result = await pdfParse(buffer);\n return result.text || '';\n }\n throw new Error(`Unsupported file type: ${ext}`);\n}\n\nasync function collectMaterialContext(briefText, files = []) {\n const fileDetails = [];\n const parts = [];\n const cleanBrief = clean(briefText);\n if (cleanBrief) {\n parts.push(`Pasted Brief:\\n${cleanBrief}`);\n }\n\n for (const file of files) {\n const text = clean(await extractMaterialFileText(file));\n fileDetails.push({\n original_name: file.originalname,\n size: file.size,\n extracted_chars: text.length\n });\n if (text) {\n parts.push(`File: ${file.originalname}\\n${text}`);\n }\n }\n\n const fullText = parts.join('\\n\\n---\\n\\n');\n if (!clean(fullText)) throw new Error('Please paste a brief or upload TXT, PDF, or DOCX files');\n const truncatedText = fullText.length > MAX_MATERIAL_CHARS\n ? fullText.slice(0, MAX_MATERIAL_CHARS)\n : fullText;\n const materialType = cleanBrief && files.length\n ? 'mixed'\n : files.length\n ? 'user_uploaded'\n : 'pasted_text';\n\n return {\n text: truncatedText,\n type: materialType,\n meta: {\n source_material_type: materialType,\n pasted_chars: cleanBrief.length,\n file_count: files.length,\n files: fileDetails,\n original_chars: fullText.length,\n used_chars: truncatedText.length,\n max_chars: MAX_MATERIAL_CHARS,\n truncated: fullText.length > MAX_MATERIAL_CHARS\n }\n };\n}\n\nasync function getSelection() {\n const row = await dbOperations.get('SELECT extra_config FROM api_settings WHERE provider = ?', [SYSTEM_SELECTION_KEY]);\n return parseJson(row?.extra_config, { aiModels: { active: 'deepseek' } });\n}\n\nasync function getAiSetting(provider) {\n const key = `ai.${provider}`;\n const direct = await dbOperations.get('SELECT * FROM api_settings WHERE provider = ?', [key]);\n if (direct?.api_key || direct?.base_url || direct?.model) return direct;\n if (provider === 'deepseek') {\n return dbOperations.get('SELECT * FROM api_settings WHERE provider = ?', ['ai']);\n }\n return direct;\n}\n\nasync function fetchJson(url, options = {}) {\n if (typeof fetch !== 'function') throw new Error('Node.js 18+ is required for AI strategy generation');\n const response = await fetch(url, options);\n const text = await response.text();\n let data = {};\n try {\n data = text ? JSON.parse(text) : {};\n } catch (error) {\n data = { raw: text };\n }\n if (!response.ok) {\n throw new Error(data?.error?.message || data?.message || `HTTP ${response.status}`);\n }\n return data;\n}\n\nasync function callOpenAiCompatible(setting, provider, systemPrompt, userPrompt) {\n if (!setting?.api_key) throw new Error(`${provider} API Key is not configured`);\n const defaultBaseUrl = provider === 'openai'\n ? 'https://api.openai.com/v1'\n : provider === 'deepseek'\n ? 'https://api.deepseek.com'\n : '';\n const baseUrl = (setting.base_url || defaultBaseUrl).replace(/\\/$/, '');\n if (!baseUrl) throw new Error(`${provider} Base URL is not configured`);\n const model = setting.model || (provider === 'deepseek' ? 'deepseek-chat' : 'gpt-4o-mini');\n\n const data = await fetchJson(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${setting.api_key}`\n },\n body: JSON.stringify({\n model,\n temperature: 0.2,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt }\n ]\n })\n });\n return data.choices?.[0]?.message?.content || '{}';\n}\n\nasync function callMiniMax(setting, systemPrompt, userPrompt) {\n if (!setting?.api_key) throw new Error('MiniMax API Key is not configured');\n const baseUrl = (setting.base_url || 'https://api.minimax.com/v1').replace(/\\/$/, '');\n const model = setting.model || 'MiniMax-M3';\n const data = await fetchJson(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${setting.api_key}`\n },\n body: JSON.stringify({\n model,\n temperature: 0.2,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt }\n ]\n })\n });\n return data.choices?.[0]?.message?.content || '{}';\n}\n\nfunction buildStrategyPrompt(strategy, campaign, materialContext = null) {\n return JSON.stringify({\n task: materialContext\n ? 'Analyze the provided product materials and generate a KOL campaign strategy draft by following the bundled kol-campaign-os-agent strategy references. Return JSON only.'\n : 'Generate a KOL campaign strategy draft by following the bundled kol-campaign-os-agent strategy references. Return JSON only.',\n campaign: {\n name: campaign?.name || strategy.name,\n brand: strategy.brand || campaign?.brand || '',\n product: strategy.product || campaign?.product || '',\n category: strategy.category || '',\n target_market: strategy.target_market || '',\n language: strategy.language || '',\n primary_platform: strategy.primary_platform || '',\n secondary_platforms: parseJson(strategy.secondary_platforms, []),\n campaign_goal: strategy.campaign_goal || ''\n },\n required_json_schema: {\n product_context: {\n product_line: '',\n key_selling_points: [],\n must_show_functions: [],\n target_users: [],\n buying_triggers: [],\n objections: [],\n price_positioning: '',\n competitors: [],\n alternatives: [],\n scenarios: []\n },\n persona_config: {\n primary_persona: '',\n secondary_personas: [],\n exclusion_personas: [],\n positive_audience_signals: [],\n negative_signals: [],\n best_content_formats: []\n },\n scoring_weights: DEFAULT_SCORING_WEIGHTS,\n finder_handoff: {\n required_platforms: [],\n required_keywords: [],\n competitor_keywords: [],\n exclusion_keywords: [],\n minimum_followers: '',\n maximum_followers: '',\n minimum_avg_views: '',\n required_evidence: [],\n approve_threshold: 75,\n tier_rules: {\n hero: 'final_score >= 85 and strong strategic fit',\n mid_tier: 'final_score 75-84 or strong niche fit',\n micro: 'final_score 65-74 with clear use-case/community value'\n }\n }\n },\n existing_strategy_sections: {\n product_context: parseJson(strategy.product_context, {}),\n persona_config: parseJson(strategy.persona_config, {}),\n scoring_weights: parseJson(strategy.scoring_weights, DEFAULT_SCORING_WEIGHTS),\n finder_handoff: parseJson(strategy.finder_handoff, {})\n },\n material_context: materialContext ? {\n source_material_type: materialContext.type,\n meta: materialContext.meta,\n text: materialContext.text\n } : undefined,\n required_extra_output: materialContext ? {\n source_material_summary: 'Summarize the AI understanding of the provided materials in Chinese or the user material language. Mention product, target user, selling points, doubts, competitors, and evidence gaps.'\n } : undefined,\n instruction: materialContext\n ? 'Base the strategy primarily on material_context. Preserve useful existing strategy details only when they do not conflict with the material. Include source_material_summary.'\n : 'Preserve useful user-provided details from existing_strategy_sections, improve weak or empty fields, and keep the output brand-agnostic and reusable across categories.'\n }, null, 2);\n}\n\nasync function generateDraft(strategy, materialContext = null) {\n const selection = await getSelection();\n const provider = selection.aiModels?.active || 'deepseek';\n if (provider === 'custom_http_api') throw new Error('Custom HTTP API is reserved and cannot generate strategy drafts yet');\n const setting = await getAiSetting(provider);\n const campaign = await dbOperations.get('SELECT * FROM campaigns WHERE id = ?', [strategy.campaign_id]);\n const skillContext = loadKolStrategySkillContext();\n const systemPrompt = [\n 'You are a senior KOL campaign strategist inside KOL Campaign OS.',\n 'Return valid JSON only. Do not include Markdown, comments, or chain-of-thought.',\n 'Follow the bundled kol-campaign-os-agent strategy references and output schema exactly.',\n skillContext || 'Use a structured KOL strategy schema with product_context, persona_config, scoring_weights, and finder_handoff.'\n ].join('\\n\\n');\n const userPrompt = buildStrategyPrompt(strategy, campaign, materialContext);\n const content = provider === 'minimax'\n ? await callMiniMax(setting, systemPrompt, userPrompt)\n : await callOpenAiCompatible(setting, provider, systemPrompt, userPrompt);\n return extractJson(content);\n}\n\nasync function getStrategy(id, { transaction = null } = {}) {\n const sql = `\n SELECT ks.*, c.name as campaign_name, cp.status AS campaign_product_status,\n cp.id AS joined_campaign_product_id, cp.product_id, p.name AS product_name\n FROM kol_strategies ks\n LEFT JOIN campaigns c ON c.id = ks.campaign_id\n LEFT JOIN campaign_products cp ON cp.id = ks.campaign_product_id\n LEFT JOIN products p ON p.id = cp.product_id\n WHERE ks.id = ?\n `;\n const row = transaction\n ? await transactionGet(sql, [id], transaction)\n : await dbOperations.get(sql, [id]);\n return normalizeStrategy(row);\n}\n\nasync function getStrategyForUpdate(id, transaction) {\n const row = await transactionGet(\n 'SELECT * FROM kol_strategies WHERE id = ? FOR UPDATE',\n [id],\n transaction\n );\n return row ? normalizeStrategy({ ...row, joined_campaign_product_id: row.campaign_product_id }) : null;\n}\n\nfunction validateReady(strategy) {\n const missing = [];\n if (!clean(strategy.name)) missing.push('Strategy name');\n if (!strategy.campaign_id) missing.push('Campaign');\n if (!clean(strategy.target_market)) missing.push('Target market');\n if (!clean(strategy.primary_platform)) missing.push('Primary platform');\n if (!clean(strategy.campaign_goal)) missing.push('Campaign goal');\n if (!Object.keys(strategy.product_context || {}).length) missing.push('Product Breakdown');\n if (!Object.keys(strategy.persona_config || {}).length) missing.push('KOL Persona');\n\n if (!Object.keys(strategy.finder_handoff || {}).length) missing.push('Finder Handoff');\n if (missing.length) throw routeError(`Cannot mark ready. Missing: ${missing.join(', ')}`, 400);\n}\n\nrouter.get('/', async (req, res) => {\n try {\n const { campaign_id, status, search } = req.query;\n let sql = `\n SELECT ks.*, c.name as campaign_name, cp.status AS campaign_product_status,\n cp.id AS joined_campaign_product_id, cp.product_id, p.name AS product_name\n FROM kol_strategies ks\n LEFT JOIN campaigns c ON c.id = ks.campaign_id\n LEFT JOIN campaign_products cp ON cp.id = ks.campaign_product_id\n LEFT JOIN products p ON p.id = cp.product_id\n WHERE 1=1\n `;\n const params = [];\n if (campaign_id) {\n sql += ' AND ks.campaign_id = ?';\n params.push(campaign_id);\n }\n if (status) {\n sql += ' AND ks.status = ?';\n params.push(status);\n }\n if (search) {\n sql += ' AND (ks.name LIKE ? OR ks.brand LIKE ? OR ks.product LIKE ? OR ks.category LIKE ? OR c.name LIKE ?)';\n const term = `%${search}%`;\n params.push(term, term, term, term, term);\n }\n sql += ' ORDER BY CASE WHEN ks.status = \"ready\" THEN 0 ELSE 1 END, ks.updated_at DESC, ks.id DESC';\n const rows = await dbOperations.query(sql, params);\n res.json({ success: true, data: rows.map(normalizeStrategy) });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n});\n\nrouter.post('/', async (req, res) => {\n try {\n const body = req.body || {};\n rejectLegacyCycleFields(body);\n const campaignId = parseBodyId(body.campaign_id);\n const campaignProductId = parseBodyId(body.campaign_product_id);\n if (campaignId === null) throw routeError('campaign_id is required and must be a positive integer', 400);\n if (campaignProductId === null) throw routeError('campaign_product_id is required and must be a positive integer', 400);\n\n const strategy = await sequelize.transaction(async (transaction) => {\n const campaign = await transactionGet(\n 'SELECT * FROM campaigns WHERE id = ? FOR UPDATE',\n [campaignId],\n transaction\n );\n if (!campaign) throw routeError('Campaign not found', 400);\n const campaignProduct = await getCampaignProductForStrategy(\n campaignId,\n campaignProductId,\n { requireActive: true, transaction }\n );\n const name = clean(body.name || `${campaign.name || 'Campaign'} Strategy`);\n const result = await transactionRun(\n `INSERT INTO kol_strategies\n (campaign_id, campaign_product_id, name, brand, product, category, target_market, language, primary_platform,\n secondary_platforms, campaign_goal, status, product_context, persona_config,\n scoring_weights, finder_handoff)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n [\n campaignId,\n campaignProduct.id,\n name,\n body.brand ?? campaign.brand ?? '',\n body.product ?? campaign.product ?? campaign.name ?? '',\n clean(body.category),\n clean(body.target_market),\n clean(body.language),\n clean(body.primary_platform),\n asJson(body.secondary_platforms, []),\n clean(body.campaign_goal),\n clean(body.status) || 'draft',\n asJson(body.product_context, {}),\n asJson(body.persona_config, {}),\n asJson(body.scoring_weights, DEFAULT_SCORING_WEIGHTS),\n asJson(body.finder_handoff, {})\n ],\n transaction\n );\n const created = await getStrategy(result.id, { transaction });\n if (!created) throw routeError('Strategy creation conflicted with another update', 409);\n return created;\n });\n res.json({ success: true, data: strategy, message: 'Strategy created' });\n } catch (error) {\n sendRouteError(res, error);\n }\n});\n\nrouter.put('/:id', async (req, res) => {\n try {\n const body = req.body || {};\n rejectLegacyCycleFields(body);\n const strategyId = parsePathId(req.params.id);\n if (strategyId === null) throw routeError('Strategy id must be a positive integer', 400);\n const requestedCampaignId = hasOwn(body, 'campaign_id') ? parseBodyId(body.campaign_id) : undefined;\n const requestedCampaignProductId = hasOwn(body, 'campaign_product_id')\n ? parseBodyId(body.campaign_product_id)\n : undefined;\n if (hasOwn(body, 'campaign_id') && requestedCampaignId === null) {\n throw routeError('campaign_id must be a positive integer', 400);\n }\n if (hasOwn(body, 'campaign_product_id') && requestedCampaignProductId === null) {\n throw routeError('campaign_product_id must be a positive integer', 400);\n }\n\n const strategy = await sequelize.transaction(async (transaction) => {\n const existing = await getStrategyForUpdate(strategyId, transaction);\n if (!existing) throw routeError('Strategy not found', 404);\n const campaignId = requestedCampaignId ?? existing.campaign_id;\n const campaignProductId = requestedCampaignProductId ?? existing.campaign_product_id;\n await getCampaignProductForStrategy(\n campaignId,\n campaignProductId,\n { requireActive: true, transaction }\n );\n await transactionRun(\n `UPDATE kol_strategies SET\n campaign_id = ?, campaign_product_id = ?, name = ?, brand = ?, product = ?, category = ?, target_market = ?,\n language = ?, primary_platform = ?, secondary_platforms = ?, campaign_goal = ?,\n product_context = ?, persona_config = ?, scoring_weights = ?,\n finder_handoff = ?, updated_at = CURRENT_TIMESTAMP\n WHERE id = ?`,\n [\n campaignId,\n campaignProductId,\n clean(body.name),\n clean(body.brand),\n clean(body.product),\n clean(body.category),\n clean(body.target_market),\n clean(body.language),\n clean(body.primary_platform),\n asJson(body.secondary_platforms, []),\n clean(body.campaign_goal),\n asJson(body.product_context, {}),\n asJson(body.persona_config, {}),\n asJson(body.scoring_weights, DEFAULT_SCORING_WEIGHTS),\n asJson(body.finder_handoff, {}),\n strategyId\n ],\n transaction\n );\n const updated = await getStrategy(strategyId, { transaction });\n if (!updated) throw routeError('Strategy update conflicted with another update', 409);\n return updated;\n });\n res.json({ success: true, data: strategy, message: 'Strategy saved' });\n } catch (error) {\n sendRouteError(res, error);\n }\n});\n\nrouter.post('/:id/generate-draft', async (req, res) => {\n try {\n const strategy = await getStrategy(req.params.id);\n if (!strategy) return res.status(404).json({ success: false, error: 'Strategy not found' });\n const draft = await generateDraft(strategy);\n const merged = {\n product_context: draft.product_context || strategy.product_context || {},\n persona_config: draft.persona_config || strategy.persona_config || {},\n scoring_weights: draft.scoring_weights || strategy.scoring_weights || DEFAULT_SCORING_WEIGHTS,\n finder_handoff: draft.finder_handoff || strategy.finder_handoff || {}\n };\n await dbOperations.run(\n `UPDATE kol_strategies SET product_context = ?, persona_config = ?,\n scoring_weights = ?, finder_handoff = ?, status = 'draft', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,\n [\n JSON.stringify(merged.product_context),\n JSON.stringify(merged.persona_config),\n JSON.stringify(merged.scoring_weights),\n JSON.stringify(merged.finder_handoff),\n req.params.id\n ]\n );\n res.json({ success: true, data: await getStrategy(req.params.id), message: 'AI draft generated' });\n } catch (error) {\n res.status(400).json({ success: false, error: error.message });\n }\n});\n\nrouter.post('/:id/analyze-materials', upload.array('files', MAX_MATERIAL_FILES), async (req, res) => {\n const files = req.files || [];\n try {\n const strategy = await getStrategy(req.params.id);\n if (!strategy) return res.status(404).json({ success: false, error: 'Strategy not found' });\n\n const materialContext = await collectMaterialContext(req.body.brief_text, files);\n const draft = await generateDraft(strategy, materialContext);\n const merged = {\n source_material_summary: clean(draft.source_material_summary),\n product_context: draft.product_context || strategy.product_context || {},\n persona_config: draft.persona_config || strategy.persona_config || {},\n scoring_weights: draft.scoring_weights || strategy.scoring_weights || DEFAULT_SCORING_WEIGHTS,\n finder_handoff: draft.finder_handoff || strategy.finder_handoff || {}\n };\n\n if (!merged.source_material_summary) {\n merged.source_material_summary = 'AI 已读取材料并生成 Strategy 草稿,但没有返回单独的材料摘要。';\n }\n\n await dbOperations.run(\n `UPDATE kol_strategies SET\n source_material_summary = ?, source_material_meta = ?, source_material_type = ?,\n research_status = ?, research_sources = ?,\n product_context = ?, persona_config = ?,\n scoring_weights = ?, finder_handoff = ?, status = 'draft',\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?`,\n [\n merged.source_material_summary,\n JSON.stringify(materialContext.meta),\n materialContext.type,\n 'not_started',\n JSON.stringify([]),\n JSON.stringify(merged.product_context),\n JSON.stringify(merged.persona_config),\n JSON.stringify(merged.scoring_weights),\n JSON.stringify(merged.finder_handoff),\n req.params.id\n ]\n );\n\n res.json({\n success: true,\n data: await getStrategy(req.params.id),\n meta: materialContext.meta,\n message: 'Materials analyzed and strategy draft generated'\n });\n } catch (error) {\n res.status(400).json({ success: false, error: error.message });\n } finally {\n files.forEach((file) => {\n if (file?.path) fs.unlink(file.path, () => {});\n });\n }\n});\n\n// mark-ready 核心逻辑(事务内):路由 /:id/mark-ready 与 approval_items 决定副作用共用。\nasync function markStrategyReady(strategyId) {\n return sequelize.transaction(async (transaction) => {\n const strategy = await getStrategyForUpdate(strategyId, transaction);\n if (!strategy) throw routeError('Strategy not found', 404);\n await getCampaignProductForStrategy(\n strategy.campaign_id,\n strategy.campaign_product_id,\n { requireActive: true, transaction }\n );\n validateReady(strategy);\n await transactionRun(\n 'UPDATE kol_strategies SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',\n ['ready', strategyId],\n transaction\n );\n const updated = await getStrategy(strategyId, { transaction });\n if (!updated) throw routeError('Strategy ready update conflicted with another update', 409);\n return updated;\n });\n}\n\nrouter.post('/:id/mark-ready', async (req, res) => {\n try {\n const body = req.body || {};\n rejectLegacyCycleFields(body);\n const strategyId = parsePathId(req.params.id);\n if (strategyId === null) throw routeError('Strategy id must be a positive integer', 400);\n const ready = await markStrategyReady(strategyId);\n res.json({ success: true, data: ready, message: 'Strategy is ready' });\n } catch (error) {\n sendRouteError(res, error);\n }\n});\n\nrouter.post('/:id/archive', async (req, res) => {\n try {\n const strategyId = parsePathId(req.params.id);\n if (strategyId === null) throw routeError('Strategy id must be a positive integer', 400);\n const outcome = await sequelize.transaction(async (transaction) => {\n const existing = await getStrategyForUpdate(strategyId, transaction);\n if (!existing) return null;\n const result = await transactionRun(\n `UPDATE kol_strategies\n SET status = 'archived', updated_at = CURRENT_TIMESTAMP\n WHERE id = ? AND status <> 'archived'`,\n [strategyId],\n transaction\n );\n const archived = await getStrategy(strategyId, { transaction });\n if (!archived) throw routeError('Strategy archive conflicted with another update', 409);\n if (archived.status !== 'archived') throw routeError('Strategy archive conflicted with another update', 409);\n return { strategy: archived, changed: result.changes > 0 };\n });\n if (!outcome) return res.status(404).json({ success: false, error: 'Strategy not found' });\n res.json({\n success: true,\n data: outcome.strategy,\n message: outcome.changed ? 'Strategy archived' : 'Strategy already archived'\n });\n } catch (error) {\n sendRouteError(res, error);\n }\n});\n\nrouter.post('/:id/duplicate', async (req, res) => {\n try {\n const strategyId = parsePathId(req.params.id);\n if (strategyId === null) throw routeError('Strategy id must be a positive integer', 400);\n const duplicated = await sequelize.transaction(async (transaction) => {\n const strategy = await getStrategyForUpdate(strategyId, transaction);\n if (!strategy) throw routeError('Strategy not found', 404);\n const campaignProduct = await getCampaignProductForStrategy(\n strategy.campaign_id,\n strategy.campaign_product_id,\n { requireActive: true, transaction }\n );\n const result = await transactionRun(\n `INSERT INTO kol_strategies\n (campaign_id, campaign_product_id, name, brand, product, category, target_market, language, primary_platform,\n secondary_platforms, campaign_goal, status, product_context, persona_config,\n scoring_weights, finder_handoff)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)`,\n [\n strategy.campaign_id,\n campaignProduct.id,\n `${strategy.name} Copy`,\n strategy.brand || '',\n strategy.product || '',\n strategy.category || '',\n strategy.target_market || '',\n strategy.language || '',\n strategy.primary_platform || '',\n JSON.stringify(strategy.secondary_platforms || []),\n strategy.campaign_goal || '',\n JSON.stringify(strategy.product_context || {}),\n JSON.stringify(strategy.persona_config || {}),\n JSON.stringify(strategy.scoring_weights || DEFAULT_SCORING_WEIGHTS),\n JSON.stringify(strategy.finder_handoff || {})\n ],\n transaction\n );\n const copy = await getStrategy(result.id, { transaction });\n if (!copy) throw routeError('Strategy duplication conflicted with another update', 409);\n return copy;\n });\n res.json({ success: true, data: duplicated, message: 'Strategy duplicated' });\n } catch (error) {\n sendRouteError(res, error);\n }\n});\n\nrouter.delete('/:id', async (req, res) => {\n try {\n const usage = await dbOperations.get('SELECT COUNT(*) as count FROM raw_candidates WHERE strategy_id = ?', [req.params.id]);\n if (usage?.count > 0) {\n return res.status(400).json({ success: false, error: `Strategy has ${usage.count} Raw Candidates and cannot be deleted. Archive it instead.` });\n }\n await dbOperations.run('DELETE FROM kol_strategies WHERE id = ?', [req.params.id]);\n res.json({ success: true, message: 'Strategy deleted' });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n});\n\nmodule.exports = router;\n// 供 approval_items 决定副作用复用(decisionDispatcher),保持与 /:id/mark-ready 同一代码路径。\nmodule.exports.markStrategyReady = markStrategyReady;\n"}}}}