server.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import { createServer } from 'node:http';
  2. import { config } from './config.ts';
  3. import { sendJson, readBody, extractPathParam, extractPathTail } from './utils/http.ts';
  4. import {
  5. handleUploadBrief,
  6. handleStartTask,
  7. handleGetTask,
  8. handleListTasks,
  9. handleExportTask,
  10. } from './routes/task.routes.ts';
  11. // ─── Legacy demo data (保留向下兼容) ──────────────────────────
  12. interface LegacyCandidate {
  13. platform: string;
  14. name: string;
  15. fans: string;
  16. price: number;
  17. score: number;
  18. styleMatch: number;
  19. status: string;
  20. reason: string;
  21. risk: string;
  22. }
  23. const demoRequirements = [
  24. { label: '客户/品牌', value: '客户A / 新锐护肤品牌', confidence: 96 },
  25. { label: '投放目标', value: '新品种草、真实测评、站内转化蓄水', confidence: 92 },
  26. { label: '平台需求', value: '小红书 20人、抖音 8人、B站 4人', confidence: 90 },
  27. { label: '内容风格', value: '真实体验、通勤生活、轻专业成分党', confidence: 88 },
  28. ];
  29. const demoRules = [
  30. '按 Brief 平台优先提号',
  31. '默认推荐数量不少于需求 3 倍',
  32. '风格匹配低于 80% 自动降级',
  33. '黑名单与低配合度剔除',
  34. '优先近期采集有效价格',
  35. ];
  36. const demoCandidates: LegacyCandidate[] = [
  37. {
  38. platform: '小红书',
  39. name: '梨涡测评室',
  40. fans: '18.6万',
  41. price: 6800,
  42. score: 94,
  43. styleMatch: 91,
  44. status: '强推荐',
  45. reason: '近10篇中 9 篇为真实体验或成分拆解,互动中位数高于同价位账号 28%。',
  46. risk: '视频内容偏少,可作为图文主推。',
  47. },
  48. {
  49. platform: '抖音',
  50. name: '林小林认真护肤',
  51. fans: '42.1万',
  52. price: 12800,
  53. score: 87,
  54. styleMatch: 82,
  55. status: '备选',
  56. reason: '爆文率和完播率表现稳定,预算接近上限,建议作为抖音头部备选。',
  57. risk: '单条报价较高,需确认档期。',
  58. },
  59. ];
  60. function escapeHtml(value: string): string {
  61. return value
  62. .replaceAll('&', '&')
  63. .replaceAll('<', '&lt;')
  64. .replaceAll('>', '&gt;')
  65. .replaceAll('"', '&quot;');
  66. }
  67. function sendExcel(response: import('node:http').ServerResponse, filename: string, candidates: LegacyCandidate[]): void {
  68. const rows = candidates
  69. .map(
  70. (candidate) => `
  71. <tr>
  72. <td>${escapeHtml(candidate.platform)}</td>
  73. <td>${escapeHtml(candidate.name)}</td>
  74. <td>${escapeHtml(candidate.fans)}</td>
  75. <td>${candidate.price}</td>
  76. <td>${candidate.score}</td>
  77. <td>${candidate.styleMatch}%</td>
  78. <td>${escapeHtml(candidate.status)}</td>
  79. <td>${escapeHtml(candidate.reason)}</td>
  80. <td>${escapeHtml(candidate.risk)}</td>
  81. </tr>`
  82. )
  83. .join('');
  84. const body = `
  85. <html>
  86. <head>
  87. <meta charset="UTF-8">
  88. <style>
  89. table { border-collapse: collapse; font-family: "Microsoft YaHei", Arial, sans-serif; }
  90. th { background: #1f8a70; color: #fff; }
  91. th, td { border: 1px solid #cfd7d1; padding: 8px 10px; }
  92. td:nth-child(5), td:nth-child(6) { background: #eef7f3; font-weight: 700; }
  93. </style>
  94. </head>
  95. <body>
  96. <table>
  97. <tr>
  98. <th>平台</th><th>账号</th><th>粉丝量</th><th>报价</th><th>综合分</th><th>风格匹配</th><th>推荐状态</th><th>推荐理由</th><th>风险提示</th>
  99. </tr>
  100. ${rows}
  101. </table>
  102. </body>
  103. </html>`;
  104. response.writeHead(200, {
  105. 'Access-Control-Allow-Origin': '*',
  106. 'Content-Type': 'application/vnd.ms-excel; charset=utf-8',
  107. 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
  108. 'Content-Length': Buffer.byteLength(body),
  109. });
  110. response.end(body);
  111. }
  112. // ─── HTTP Server ──────────────────────────────────────────────
  113. const server = createServer(async (request, response) => {
  114. const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
  115. const { pathname } = requestUrl;
  116. // CORS preflight
  117. if (request.method === 'OPTIONS') {
  118. sendJson(response, 200, { ok: true });
  119. return;
  120. }
  121. // ── 新接口:任务流水线 ────────────────────────────
  122. // POST /api/tasks/upload-brief - 上传 Brief
  123. if (request.method === 'POST' && pathname === '/api/tasks/upload-brief') {
  124. await handleUploadBrief(request, response);
  125. return;
  126. }
  127. // GET /api/tasks - 任务列表
  128. if (request.method === 'GET' && pathname === '/api/tasks') {
  129. handleListTasks(response);
  130. return;
  131. }
  132. // GET /api/tasks/:taskId - 任务详情/进度
  133. if (request.method === 'GET' && pathname.startsWith('/api/tasks/')) {
  134. const taskId = extractPathParam(pathname, '/api/tasks/');
  135. if (taskId) {
  136. handleGetTask(response, taskId);
  137. return;
  138. }
  139. }
  140. // POST /api/tasks/:taskId/start - 启动任务
  141. if (request.method === 'POST' && pathname.match(/^\/api\/tasks\/[^/]+\/start$/)) {
  142. const taskId = extractPathParam(pathname, '/api/tasks/');
  143. handleStartTask(request, response, taskId);
  144. return;
  145. }
  146. // POST /api/tasks/:taskId/export - 导出推荐表
  147. if (request.method === 'POST' && pathname.match(/^\/api\/tasks\/[^/]+\/export$/)) {
  148. const tail = extractPathTail(pathname, '/api/tasks/');
  149. const taskId = tail.replace('/export', '');
  150. handleExportTask(response, taskId);
  151. return;
  152. }
  153. // ── 旧接口:保留向下兼容 ──────────────────────────
  154. if (request.method === 'GET' && pathname === '/api/health') {
  155. sendJson(response, 200, {
  156. ok: true,
  157. name: '提号AI API',
  158. resourcesPath: 'D:\\公司优秀媒体资源库\\4月份资源库',
  159. version: '2.0.0',
  160. features: ['task-pipeline', 'brief-upload', 'justone-api', 'tikhub-api', 'export'],
  161. });
  162. return;
  163. }
  164. if (request.method === 'POST' && pathname === '/api/analyze-brief') {
  165. sendJson(response, 200, {
  166. requirements: demoRequirements,
  167. rules: demoRules,
  168. message: 'Demo 已模拟解析 Brief,真实环境会接入 Excel/Docx 解析与 LLM 抽取。',
  169. });
  170. return;
  171. }
  172. if (request.method === 'POST' && pathname === '/api/recommend') {
  173. sendJson(response, 200, {
  174. candidates: demoCandidates,
  175. totalPool: 104,
  176. message: 'Demo 已模拟按规则生成推荐池。',
  177. });
  178. return;
  179. }
  180. if (request.method === 'POST' && pathname === '/api/rules/learn') {
  181. sendJson(response, 200, {
  182. suggestions: [
  183. { title: '小红书减少泛美妆号,优先生活方式和通勤场景', confidence: 93 },
  184. { title: '抖音预算压低时保留爆文率高账号', confidence: 87 },
  185. ],
  186. });
  187. return;
  188. }
  189. if (request.method === 'POST' && pathname === '/api/recommendations/export') {
  190. const body = await readBody(request);
  191. const candidates = Array.isArray(body['candidates'])
  192. ? (body['candidates'] as LegacyCandidate[])
  193. : demoCandidates;
  194. sendExcel(response, '客户A-新锐护肤品牌-需求推荐名单.xls', candidates);
  195. return;
  196. }
  197. sendJson(response, 404, { ok: false, message: 'Not found' });
  198. });
  199. server.listen(config.port, () => {
  200. console.log(`提号AI API listening on http://localhost:${config.port}`);
  201. console.log('新接口:');
  202. console.log(' POST /api/tasks/upload-brief - 上传 Brief 文件');
  203. console.log(' POST /api/tasks/:id/start - 启动任务处理');
  204. console.log(' GET /api/tasks/:id - 查询任务进度');
  205. console.log(' GET /api/tasks - 任务列表');
  206. console.log(' POST /api/tasks/:id/export - 导出推荐表');
  207. console.log('旧接口: /api/health, /api/analyze-brief, /api/recommend, /api/rules/learn, /api/recommendations/export');
  208. });