Просмотр исходного кода

feat: add pg rag creator library workflow

cb 4 месяцев назад
Родитель
Сommit
2b57d2406c

+ 1 - 0
.gitignore

@@ -13,6 +13,7 @@ yarn-error.log
 *.log
 *.err.log
 output/
+/outputs/
 .playwright-cli/
 
 # IDEs and editors

+ 98 - 0
docs/rag-creator-database-requirements.md

@@ -0,0 +1,98 @@
+# AI 提号数据库与检索需求整理
+
+## 目标
+
+用户上传 Brief 或需求文件后,服务端自动完成需求解析、达人库检索、外部 API 兜底、推荐排序和名单导出。系统应优先使用自有达人库;自有库不足时再调用 JustOne API 补充候选,并将 JustOne 返回的原始数据全部沉淀,作为后续自有库建设和 RAG 检索的数据缓存。
+
+## 检索优先级
+
+1. 已清洗达人库:来自公司明确归类的达人表、用户上传的本地资源库,以及后续人工确认后的达人数据。
+2. 未清洗接口缓存:JustOne API 等外部接口返回的原始数据,不论当前是否符合 Brief,都需保存。
+3. 外部 API 兜底:当数据库无法召回足够候选时,调用 JustOne API 搜索。
+
+## 数据有效期
+
+- 已清洗达人库以业务更新为准,可长期保存,并记录来源、更新时间和字段完整度。
+- 未清洗接口缓存默认有效期 30 天。超过 30 天的数据不参与自动推荐,只保留为历史归档和后续分析材料。
+
+## RAG 友好的线上表结构建议
+
+### creator_profile_clean
+
+用于已清洗、可直接推荐的达人主表。
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| id | uuid / bigint | 主键 |
+| platform | varchar | 平台,如 xiaohongshu、douyin |
+| platform_user_id | varchar | 平台账号 ID |
+| display_name | varchar | 昵称 |
+| gender | varchar | 性别 |
+| fans_count | bigint | 粉丝数,统一存真实数量 |
+| liked_collect_count | bigint | 赞藏/互动数,统一存真实数量 |
+| content_type | text | 原始内容类型文本 |
+| content_tags | jsonb | 结构化内容标签 |
+| persona_tags | jsonb | 人设/风格标签 |
+| city | varchar | 业务城市 |
+| geo_location | varchar | 真实地理位置 |
+| profile_url | text | 主页链接 |
+| cooperation_method | varchar | 合作方式 |
+| image_price | int | 图文报价 |
+| video_price | int | 视频报价 |
+| min_price | int | 最低报价 |
+| source_kind | varchar | uploaded/company/confirmed_api |
+| source_file | text | 上传文件或来源 |
+| source_confidence | int | 来源可信度 |
+| updated_at | timestamp | 最近更新时间 |
+| embedding_text | text | 用于向量化的拼接文本 |
+| embedding | vector | 向量字段,后续接 pgvector/Milvus 等 |
+
+建议唯一索引:`(platform, platform_user_id)`。
+
+### provider_raw_cache
+
+用于保存 JustOne API 等接口返回的未清洗原始数据。
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| id | uuid / bigint | 主键 |
+| provider | varchar | justone/tikhub 等 |
+| endpoint | text | 请求接口 |
+| request_params | jsonb | 请求参数 |
+| response_body | jsonb | 原始响应 |
+| fetched_at | timestamp | 获取时间 |
+| expires_at | timestamp | 默认 fetched_at + 30 天 |
+| normalized_status | varchar | pending/normalized/rejected |
+| archive_path | text | 本地归档路径 |
+
+建议索引:`provider, fetched_at, expires_at`,以及对 `request_params` 建 GIN 索引。
+
+### creator_retrieval_event
+
+用于记录每次 Brief 检索过程,方便复盘。
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| id | uuid / bigint | 主键 |
+| task_id | varchar | 提号任务 ID |
+| query_text | text | Brief/检索文本 |
+| criteria | jsonb | 结构化搜索条件 |
+| local_hit_count | int | 本地库命中数 |
+| provider_hit_count | int | 外部 API 命中数 |
+| final_count | int | 最终候选数 |
+| created_at | timestamp | 发生时间 |
+
+## 本地开发阶段实现策略
+
+当前开发环境已经升级为本地 PostgreSQL:
+
+- 数据库:`tihao_ai`
+- 应用用户:`tihao_ai_app`
+- 初始化脚本:`npm run db:init`
+- 表结构脚本:`server/db/schema.sql`
+- Clean 达人表:`creator_profile_clean`
+- 未清洗接口缓存表:`provider_raw_cache`
+- 检索事件表:`creator_retrieval_event`
+- JustOne 原始响应归档:`docs/provider-archives/`
+
+本地先使用 `double precision[]` 保存向量,并通过 PostgreSQL 函数 `cosine_similarity` 做余弦相似度检索。上线环境如安装 `pgvector`,可将 `embedding` 字段迁移为 `vector` 类型,并保留当前服务层的查询编排逻辑。

+ 159 - 2
package-lock.json

@@ -14,6 +14,8 @@
         "@angular/forms": "^20.0.0",
         "@angular/platform-browser": "^20.0.0",
         "@angular/router": "^20.0.0",
+        "@types/pg": "^8.20.0",
+        "pg": "^8.21.0",
         "rxjs": "~7.8.0",
         "tslib": "^2.3.0",
         "zone.js": "~0.15.0"
@@ -3517,12 +3519,22 @@
       "version": "25.6.0",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
       "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "undici-types": "~7.19.0"
       }
     },
+    "node_modules/@types/pg": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
+      "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "pg-protocol": "*",
+        "pg-types": "^2.2.0"
+      }
+    },
     "node_modules/@types/ws": {
       "version": "8.18.1",
       "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -7510,6 +7522,95 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/pg": {
+      "version": "8.21.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
+      "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.13.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.14.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.13.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
+      "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
+      "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
     "node_modules/picocolors": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7589,6 +7690,45 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/proc-log": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
@@ -8335,6 +8475,15 @@
       "dev": true,
       "license": "CC0-1.0"
     },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
     "node_modules/ssri": {
       "version": "13.0.1",
       "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz",
@@ -8614,7 +8763,6 @@
       "version": "7.19.2",
       "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
       "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/universalify": {
@@ -9437,6 +9585,15 @@
         }
       }
     },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    },
     "node_modules/y18n": {
       "version": "5.0.8",
       "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

+ 3 - 0
package.json

@@ -6,6 +6,7 @@
     "ng": "ng",
     "start": "ng serve",
     "api": "node --experimental-strip-types --experimental-transform-types server/server.ts",
+    "db:init": "node --experimental-strip-types --experimental-transform-types server/db/init-db.ts",
     "build": "ng build",
     "watch": "ng build --watch --configuration development",
     "test": "ng test"
@@ -28,6 +29,8 @@
     "@angular/forms": "^20.0.0",
     "@angular/platform-browser": "^20.0.0",
     "@angular/router": "^20.0.0",
+    "@types/pg": "^8.20.0",
+    "pg": "^8.21.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
     "zone.js": "~0.15.0"

+ 48 - 0
server/db/init-db.ts

@@ -0,0 +1,48 @@
+import { readFile } from 'node:fs/promises';
+import { Client } from 'pg';
+
+const adminConnectionString = process.env.DATABASE_ADMIN_URL || 'postgres://postgres:postgres@localhost:5432/postgres';
+const appDatabase = process.env.PGAPP_DATABASE || 'tihao_ai';
+const appUser = process.env.PGAPP_USER || 'tihao_ai_app';
+const appPassword = process.env.PGAPP_PASSWORD || 'tihao_ai_app';
+
+async function main(): Promise<void> {
+  const admin = new Client({ connectionString: adminConnectionString });
+  await admin.connect();
+
+  await admin.query(`DO $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${escapeLiteral(appUser)}') THEN
+    CREATE ROLE ${quoteIdent(appUser)} LOGIN PASSWORD '${escapeLiteral(appPassword)}';
+  END IF;
+END
+$$;`);
+
+  const dbExists = await admin.query('SELECT 1 FROM pg_database WHERE datname = $1', [appDatabase]);
+  if (dbExists.rowCount === 0) {
+    await admin.query(`CREATE DATABASE ${quoteIdent(appDatabase)} OWNER ${quoteIdent(appUser)}`);
+  }
+
+  await admin.end();
+
+  const schema = await readFile('server/db/schema.sql', 'utf-8');
+  const app = new Client({ connectionString: process.env.DATABASE_URL || `postgres://${appUser}:${appPassword}@localhost:5432/${appDatabase}` });
+  await app.connect();
+  await app.query(schema);
+  await app.end();
+
+  console.log(`Database initialized: ${appDatabase}`);
+}
+
+function quoteIdent(value: string): string {
+  return `"${value.replace(/"/g, '""')}"`;
+}
+
+function escapeLiteral(value: string): string {
+  return value.replace(/'/g, "''");
+}
+
+main().catch((error) => {
+  console.error(error);
+  process.exit(1);
+});

+ 86 - 0
server/db/schema.sql

@@ -0,0 +1,86 @@
+CREATE TABLE IF NOT EXISTS creator_profile_clean (
+  id BIGSERIAL PRIMARY KEY,
+  platform TEXT NOT NULL,
+  platform_user_id TEXT NOT NULL,
+  display_name TEXT NOT NULL,
+  gender TEXT,
+  fans_count BIGINT DEFAULT 0,
+  liked_collect_count BIGINT DEFAULT 0,
+  content_type TEXT,
+  content_tags JSONB DEFAULT '[]'::jsonb,
+  persona_tags JSONB DEFAULT '[]'::jsonb,
+  city TEXT,
+  geo_location TEXT,
+  profile_url TEXT,
+  cooperation_method TEXT,
+  image_price INTEGER DEFAULT 0,
+  video_price INTEGER DEFAULT 0,
+  min_price INTEGER DEFAULT 0,
+  cooperation_status TEXT,
+  source_kind TEXT NOT NULL DEFAULT 'uploaded',
+  source_provider TEXT NOT NULL DEFAULT 'local-upload',
+  source_file TEXT,
+  source_confidence INTEGER DEFAULT 90,
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+  embedding_text TEXT NOT NULL DEFAULT '',
+  embedding DOUBLE PRECISION[] NOT NULL DEFAULT '{}',
+  UNIQUE(platform, platform_user_id)
+);
+
+CREATE TABLE IF NOT EXISTS provider_raw_cache (
+  id BIGSERIAL PRIMARY KEY,
+  provider TEXT NOT NULL,
+  endpoint TEXT NOT NULL,
+  request_params JSONB NOT NULL DEFAULT '{}'::jsonb,
+  response_body JSONB NOT NULL DEFAULT '{}'::jsonb,
+  fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+  expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '30 days',
+  normalized_status TEXT NOT NULL DEFAULT 'pending',
+  archive_path TEXT
+);
+
+CREATE TABLE IF NOT EXISTS creator_retrieval_event (
+  id BIGSERIAL PRIMARY KEY,
+  task_id TEXT,
+  query_text TEXT NOT NULL DEFAULT '',
+  criteria JSONB NOT NULL DEFAULT '{}'::jsonb,
+  local_hit_count INTEGER NOT NULL DEFAULT 0,
+  provider_hit_count INTEGER NOT NULL DEFAULT 0,
+  final_count INTEGER NOT NULL DEFAULT 0,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_creator_clean_platform ON creator_profile_clean(platform);
+CREATE INDEX IF NOT EXISTS idx_creator_clean_city ON creator_profile_clean(city);
+CREATE INDEX IF NOT EXISTS idx_creator_clean_updated_at ON creator_profile_clean(updated_at);
+CREATE INDEX IF NOT EXISTS idx_creator_clean_content_tags ON creator_profile_clean USING GIN(content_tags);
+CREATE INDEX IF NOT EXISTS idx_provider_raw_provider_time ON provider_raw_cache(provider, fetched_at DESC);
+CREATE INDEX IF NOT EXISTS idx_provider_raw_expires_at ON provider_raw_cache(expires_at);
+CREATE INDEX IF NOT EXISTS idx_provider_raw_request_params ON provider_raw_cache USING GIN(request_params);
+
+CREATE OR REPLACE FUNCTION cosine_similarity(a DOUBLE PRECISION[], b DOUBLE PRECISION[])
+RETURNS DOUBLE PRECISION
+LANGUAGE SQL
+IMMUTABLE
+AS $$
+  WITH pairs AS (
+    SELECT
+      av.val AS av,
+      bv.val AS bv
+    FROM unnest(a) WITH ORDINALITY AS av(val, idx)
+    JOIN unnest(b) WITH ORDINALITY AS bv(val, idx)
+      ON av.idx = bv.idx
+  ),
+  sums AS (
+    SELECT
+      SUM(av * bv) AS dot,
+      SQRT(SUM(av * av)) AS norm_a,
+      SQRT(SUM(bv * bv)) AS norm_b
+    FROM pairs
+  )
+  SELECT CASE
+    WHEN norm_a = 0 OR norm_b = 0 OR norm_a IS NULL OR norm_b IS NULL THEN 0
+    ELSE dot / (norm_a * norm_b)
+  END
+  FROM sums;
+$$;

+ 33 - 0
server/routes/task.routes.ts

@@ -3,6 +3,7 @@ import { createTask, getTask, getAllTasks, runTaskPipeline } from '../services/t
 import { generateExcelBuffer } from '../services/export.service.ts';
 import { extractTextFromBuffer } from '../utils/file-parser.ts';
 import { sendJson, readMultipartFile } from '../utils/http.ts';
+import { ingestUploadedCreatorLibrary } from '../services/local-creator-db.service.ts';
 
 /**
  * POST /api/tasks/upload-brief
@@ -24,11 +25,16 @@ export async function handleUploadBrief(request: IncomingMessage, response: Serv
 
     // 创建任务
     const task = createTask(fileName, briefText);
+    const localCreatorCount = await ingestUploadedCreatorLibrary(buffer, fileName);
     console.log(`[Route] 任务已创建: ${task.id}, briefText长度: ${briefText.length}`);
+    if (localCreatorCount > 0) {
+      console.log(`[Route] 已从上传文件入库本地达人: ${localCreatorCount} 位`);
+    }
 
     sendJson(response, 200, {
       taskId: task.id,
       fileName: task.fileName,
+      localCreatorCount,
       message: `文件 ${fileName} 已上传,任务已创建`,
     });
   } catch (error) {
@@ -37,6 +43,33 @@ export async function handleUploadBrief(request: IncomingMessage, response: Serv
   }
 }
 
+/**
+ * POST /api/creator-library/upload
+ * 上传本地达人资料库文件,入库到 PostgreSQL 清洗达人表
+ */
+export async function handleUploadCreatorLibrary(request: IncomingMessage, response: ServerResponse): Promise<void> {
+  try {
+    console.log('[Route] 收到达人资料库上传请求, Content-Type:', request.headers['content-type']);
+    const { fileName, buffer } = await readMultipartFile(request);
+
+    if (!buffer || buffer.length === 0) {
+      sendJson(response, 400, { ok: false, message: '未收到文件内容' });
+      return;
+    }
+
+    const localCreatorCount = await ingestUploadedCreatorLibrary(buffer, fileName);
+    sendJson(response, 200, {
+      ok: true,
+      fileName,
+      localCreatorCount,
+      message: `达人资料库 ${fileName} 已入库 ${localCreatorCount} 位达人`,
+    });
+  } catch (error) {
+    console.error('[CreatorLibraryUpload] Error:', error);
+    sendJson(response, 500, { ok: false, message: error instanceof Error ? error.message : '达人资料库上传失败' });
+  }
+}
+
 /**
  * POST /api/tasks/:taskId/start
  * 启动任务处理流水线(异步执行)

+ 7 - 0
server/server.ts

@@ -3,6 +3,7 @@ import { config } from './config.ts';
 import { sendJson, readBody, extractPathParam, extractPathTail } from './utils/http.ts';
 import {
   handleUploadBrief,
+  handleUploadCreatorLibrary,
   handleStartTask,
   handleGetTask,
   handleListTasks,
@@ -139,6 +140,12 @@ const server = createServer(async (request, response) => {
     return;
   }
 
+  // POST /api/creator-library/upload - 上传本地达人资料库
+  if (request.method === 'POST' && pathname === '/api/creator-library/upload') {
+    await handleUploadCreatorLibrary(request, response);
+    return;
+  }
+
   // GET /api/tasks - 任务列表
   if (request.method === 'GET' && pathname === '/api/tasks') {
     handleListTasks(response);

+ 31 - 3
server/services/export.service.ts

@@ -36,6 +36,17 @@ export function generateExcelBuffer(candidates: NormalizedCandidate[], taskFileN
       <th>推荐理由</th>
       <th>风险提示</th>
       <th>数据来源</th>
+      <th>昵称</th>
+      <th>性别</th>
+      <th>粉丝数(万)</th>
+      <th>赞藏数(万)</th>
+      <th>内容类型</th>
+      <th>城市</th>
+      <th>地理位置</th>
+      <th>小红书主页</th>
+      <th>合作方式</th>
+      <th>图文笔记报价(含平台服务费)</th>
+      <th>视频笔记报价(含平台服务费)</th>
     </tr>`;
 
   const rows = candidates.map((c, index) => `
@@ -57,6 +68,17 @@ export function generateExcelBuffer(candidates: NormalizedCandidate[], taskFileN
       <td>${escapeHtml(c.recommendReason || '-')}</td>
       <td>${escapeHtml(c.riskNote || '-')}</td>
       <td>${escapeHtml(c.sourceProvider)}</td>
+      <td>${escapeHtml(c.displayName)}</td>
+      <td>${escapeHtml(c.gender || '-')}</td>
+      <td>${formatWan(c.fansCount)}</td>
+      <td>${formatWan(c.likedCollectCount || 0)}</td>
+      <td>${escapeHtml(c.contentType || c.contentTags.join('、') || '-')}</td>
+      <td>${escapeHtml(c.city || c.location || '-')}</td>
+      <td>${escapeHtml(c.geoLocation || c.location || '-')}</td>
+      <td>${escapeHtml(c.xiaohongshuUrl || (c.platform === 'xiaohongshu' ? c.profileUrl : '-'))}</td>
+      <td>${escapeHtml(c.cooperationMethod || '-')}</td>
+      <td>${c.imagePrice > 0 ? `¥${c.imagePrice}` : '-'}</td>
+      <td>${c.videoPrice > 0 ? `¥${c.videoPrice}` : '-'}</td>
     </tr>`
   ).join('');
 
@@ -92,9 +114,9 @@ export function generateExcelBuffer(candidates: NormalizedCandidate[], taskFileN
 </head>
 <body>
   <table>
-    <tr class="title-row"><td colspan="17">${escapeHtml(title)} - 媒体资源推荐表</td></tr>
-    <tr class="meta-row"><td colspan="17">生成时间:${now} | 候选总数:${candidates.length} | 强推荐:${candidates.filter(c => c.recommendStatus === '强推荐').length} | 备选:${candidates.filter(c => c.recommendStatus === '备选').length}</td></tr>
-    <tr><td colspan="17"></td></tr>
+    <tr class="title-row"><td colspan="28">${escapeHtml(title)} - 媒体资源推荐表</td></tr>
+    <tr class="meta-row"><td colspan="28">生成时间:${now} | 候选总数:${candidates.length} | 强推荐:${candidates.filter(c => c.recommendStatus === '强推荐').length} | 备选:${candidates.filter(c => c.recommendStatus === '备选').length}</td></tr>
+    <tr><td colspan="28"></td></tr>
     ${headerRow}
     ${rows}
   </table>
@@ -120,3 +142,9 @@ function formatFans(count: number): string {
   if (count >= 10000) return `${(count / 10000).toFixed(1)}万`;
   return String(count);
 }
+
+function formatWan(count: number): string {
+  if (!count) return '-';
+  const value = count >= 10000 ? count / 10000 : count;
+  return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
+}

+ 103 - 8
server/services/justone.service.ts

@@ -1,4 +1,6 @@
 import { config } from '../config.ts';
+import type { ContentSample } from './tikhub.service.ts';
+import { archiveProviderResponse } from './local-creator-db.service.ts';
 
 export interface JustOneCreator {
   userId: string;
@@ -13,7 +15,15 @@ export interface JustOneCreator {
   cooperationStatus: string;
   personalTags: string[];
   contentTags: string[];
+  sourceProvider?: string;
   gender?: string;
+  likedCollectCount?: number;
+  contentType?: string;
+  city?: string;
+  geoLocation?: string;
+  xiaohongshuUrl?: string;
+  cooperationMethod?: string;
+  contentSamples?: ContentSample[];
 }
 
 interface SearchParams {
@@ -87,6 +97,12 @@ export async function searchCreators(params: SearchParams): Promise<JustOneCreat
     }
 
     const data = await response.json();
+    await archiveProviderResponse({
+      provider: 'justone',
+      endpoint,
+      requestParams: Object.fromEntries(queryParams.entries()),
+      responseBody: data,
+    });
     console.log('[JustOne] 原始响应 code:', data.code, 'message:', data.message);
 
     // code 301 = FAILED, RETRY——重试一次
@@ -99,6 +115,12 @@ export async function searchCreators(params: SearchParams): Promise<JustOneCreat
         return [];
       }
       const retryData = await retryResp.json();
+      await archiveProviderResponse({
+        provider: 'justone',
+        endpoint,
+        requestParams: { ...Object.fromEntries(queryParams.entries()), retry: true },
+        responseBody: retryData,
+      });
       console.log('[JustOne] 重试响应 code:', retryData.code, 'message:', retryData.message);
       if (retryData.code !== 0) {
         console.error('[JustOne] 重试仍失败, code:', retryData.code);
@@ -211,6 +233,12 @@ function parseXhsCreator(item: Record<string, unknown>): JustOneCreator {
     personalTags: featureTags,
     contentTags,
     gender: genderMap[genderRaw] || genderRaw,
+    likedCollectCount: Number(item.likedCollectCount || item.likeCollectCount || item.interactionCount || 0),
+    contentType: contentTags.join('、'),
+    city: String(item.city || item.location || ''),
+    geoLocation: String(item.location || item.city || ''),
+    xiaohongshuUrl: item.userId ? `https://www.xiaohongshu.com/user/profile/${String(item.userId || item.user_id)}` : '',
+    cooperationMethod: '',
   };
 }
 
@@ -219,10 +247,7 @@ function parseDouyinXingtuCreator(item: Record<string, unknown>): JustOneCreator
   const attr = (item.attribute_datas || {}) as Record<string, string>;
 
   const fansCount = Number(attr.follower || attr.fans_count || 0);
-  // assign_cpm_suggest_price 是 CPM 建议价(元/千次),不是合作报价,暂设为0
-  const imagePrice = 0;
-  const videoPrice = 0;
-  const minPrice = 0;
+  const prices = parseDouyinTaskPrices(item.task_infos);
 
   const genderCode = attr.gender || '0';
   const genderMap: Record<string, string> = { '1': 'male', '2': 'female', '0': '' };
@@ -253,6 +278,8 @@ function parseDouyinXingtuCreator(item: Record<string, unknown>): JustOneCreator
     } catch { /* ignore */ }
   }
 
+  contentTags = [...new Set([...contentTags, ...parseDouyinTagsRelation(attr.tags_relation)])].slice(0, 12);
+
   // author_thin_mid_word_association_index 是 JSON 对象 {词: 权重},取 key 作为标签
   let personalTags: string[] = [];
   try {
@@ -264,19 +291,87 @@ function parseDouyinXingtuCreator(item: Record<string, unknown>): JustOneCreator
   return {
     userId: String(item.star_id || attr.id || attr.core_user_id || ''),
     platform: 'douyin',
-    nickname: String(attr.nickname || attr.name || ''),
+    nickname: String(attr.nick_name || attr.nickname || attr.name || ''),
     location: String(attr.city || attr.province || ''),
     fansCount,
-    imagePrice,
-    videoPrice,
-    minPrice,
+    imagePrice: prices.imagePrice,
+    videoPrice: prices.videoPrice,
+    minPrice: prices.minPrice,
     cooperationStatus: attr.author_status === '1' ? 'active' : '',
     personalTags,
     contentTags,
     gender: genderMap[genderCode] || '',
+    likedCollectCount: Number(attr.total_favorited || attr.total_favorite || 0),
+    contentType: contentTags.join('、'),
+    city: String(attr.city || ''),
+    geoLocation: String(attr.province || attr.city || ''),
+    cooperationMethod: prices.videoPrice > 0 ? '报备视频' : '',
+    contentSamples: parseDouyinRecentItems(attr.last_10_items),
+  };
+}
+
+function parseDouyinTaskPrices(rawTaskInfos: unknown): { imagePrice: number; videoPrice: number; minPrice: number } {
+  const priceInfos: number[] = [];
+  if (Array.isArray(rawTaskInfos)) {
+    for (const task of rawTaskInfos) {
+      if (typeof task !== 'object' || task === null) continue;
+      const infos = (task as Record<string, unknown>).price_infos;
+      if (!Array.isArray(infos)) continue;
+      for (const info of infos) {
+        if (typeof info !== 'object' || info === null) continue;
+        const price = Number((info as Record<string, unknown>).price || 0);
+        const videoTypeStatus = Number((info as Record<string, unknown>).video_type_status ?? 1);
+        if (price > 0 && videoTypeStatus === 1) priceInfos.push(price);
+      }
+    }
+  }
+
+  const sortedPrices = [...new Set(priceInfos)].sort((a, b) => a - b);
+  const minPrice = sortedPrices[0] || 0;
+  const videoPrice = sortedPrices.find((price) => price >= 1000) || minPrice;
+  return {
+    imagePrice: 0,
+    videoPrice,
+    minPrice,
   };
 }
 
+function parseDouyinRecentItems(rawItems?: string): ContentSample[] {
+  try {
+    const items = JSON.parse(rawItems || '[]') as Record<string, unknown>[];
+    if (!Array.isArray(items)) return [];
+    return items.slice(0, 10).map((item) => ({
+      noteId: String(item.item_id || ''),
+      title: String(item.item_title || ''),
+      content: String(item.item_title || ''),
+      publishTime: Number(item.item_publish_time || item.item_create_time || 0) > 0
+        ? new Date(Number(item.item_publish_time || item.item_create_time) * 1000).toISOString()
+        : '',
+      likeCount: Number(item.like_cnt || 0),
+      commentCount: Number(item.comment_cnt || 0),
+      collectCount: 0,
+      shareCount: Number(item.share_cnt || 0),
+      type: 'video',
+    }));
+  } catch {
+    return [];
+  }
+}
+
+function parseDouyinTagsRelation(raw?: string): string[] {
+  try {
+    const parsed = JSON.parse(raw || '{}') as Record<string, unknown>;
+    const tags: string[] = [];
+    for (const [category, children] of Object.entries(parsed)) {
+      tags.push(category);
+      if (Array.isArray(children)) tags.push(...children.map(String));
+    }
+    return tags.filter(Boolean);
+  } catch {
+    return [];
+  }
+}
+
 function toStringArray(val: unknown): string[] {
   if (!Array.isArray(val)) return [];
   return val

+ 437 - 0
server/services/local-creator-db.service.ts

@@ -0,0 +1,437 @@
+import { mkdir, writeFile } from 'node:fs/promises';
+import { Pool } from 'pg';
+import type { JustOneCreator } from './justone.service.ts';
+import { extractWorkbookTablesFromBuffer } from '../utils/file-parser.ts';
+
+const PROVIDER_ARCHIVE_DIR = 'docs/provider-archives';
+const RAW_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+const EMBEDDING_DIMENSION = 384;
+
+const pool = new Pool({
+  connectionString: process.env.DATABASE_URL || 'postgres://tihao_ai_app:tihao_ai_app@localhost:5432/tihao_ai',
+});
+
+interface SearchCriteria {
+  platforms: string[];
+  keywords: string[];
+  fanRange: { min: number; max: number };
+  budgetRange: { min: number; max: number };
+  region?: string | string[];
+  gender?: string;
+  contentTags?: string[];
+  excludeTags?: string[];
+}
+
+interface ParsedCreator extends JustOneCreator {
+  sourceKind: 'uploaded' | 'company' | 'confirmed_api';
+  sourceFile?: string;
+  embeddingText: string;
+  sourceConfidence?: number;
+}
+
+export async function ingestUploadedCreatorLibrary(buffer: Buffer, fileName: string): Promise<number> {
+  const sheets = extractWorkbookTablesFromBuffer(buffer);
+  const creators = sheets.flatMap((sheet) => parseCreatorRows(sheet.rows, fileName));
+  if (creators.length === 0) return 0;
+
+  await upsertCleanCreators(creators);
+  return creators.length;
+}
+
+export async function searchLocalCreators(criteria: SearchCriteria, limit = 200): Promise<JustOneCreator[]> {
+  const normalized = normalizeCriteria(criteria);
+  const queryText = buildCriteriaEmbeddingText(normalized);
+  const embedding = buildEmbedding(queryText);
+
+  const platformFilters = normalized.platforms.map(mapPlatform).filter(Boolean);
+  const params: unknown[] = [embedding, limit];
+  const where: string[] = [];
+
+  if (platformFilters.length > 0) {
+    params.push(platformFilters);
+    where.push(`platform = ANY($${params.length}::text[])`);
+  }
+  if (normalized.fanRange?.min) {
+    params.push(normalized.fanRange.min);
+    where.push(`fans_count >= $${params.length}`);
+  }
+  if (normalized.fanRange?.max) {
+    params.push(normalized.fanRange.max);
+    where.push(`fans_count <= $${params.length}`);
+  }
+
+  const sql = `
+    SELECT
+      *,
+      cosine_similarity(embedding, $1::double precision[]) AS similarity
+    FROM creator_profile_clean
+    ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
+    ORDER BY
+      cosine_similarity(embedding, $1::double precision[]) DESC,
+      updated_at DESC
+    LIMIT $2
+  `;
+
+  const result = await pool.query(sql, params);
+  return result.rows
+    .map((row) => ({ creator: rowToCreator(row), score: scoreRetrievedCreator(row, normalized) }))
+    .filter((item) => item.score > 0)
+    .sort((a, b) => b.score - a.score)
+    .slice(0, limit)
+    .map((item) => item.creator);
+}
+
+export async function archiveProviderResponse(params: {
+  provider: string;
+  endpoint: string;
+  requestParams: Record<string, unknown>;
+  responseBody: unknown;
+}): Promise<void> {
+  const fetchedAt = new Date();
+  const expiresAt = new Date(fetchedAt.getTime() + RAW_CACHE_TTL_MS);
+  const safeEndpoint = params.endpoint.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 80);
+  const archiveDir = `${PROVIDER_ARCHIVE_DIR}/${fetchedAt.toISOString().slice(0, 7)}`;
+  await mkdir(archiveDir, { recursive: true });
+
+  const id = `${params.provider}_${fetchedAt.getTime()}_${Math.random().toString(36).slice(2, 8)}`;
+  const archivePath = `${archiveDir}/${id}_${safeEndpoint}.json`;
+  await writeFile(archivePath, JSON.stringify(params, null, 2), 'utf-8');
+
+  await pool.query(
+    `INSERT INTO provider_raw_cache
+      (provider, endpoint, request_params, response_body, fetched_at, expires_at, normalized_status, archive_path)
+     VALUES ($1, $2, $3::jsonb, $4::jsonb, $5, $6, 'pending', $7)`,
+    [
+      params.provider,
+      params.endpoint,
+      JSON.stringify(params.requestParams),
+      JSON.stringify(params.responseBody),
+      fetchedAt,
+      expiresAt,
+      archivePath,
+    ],
+  );
+}
+
+export async function recordRetrievalEvent(params: {
+  taskId?: string;
+  queryText: string;
+  criteria: unknown;
+  localHitCount: number;
+  providerHitCount: number;
+  finalCount: number;
+}): Promise<void> {
+  await pool.query(
+    `INSERT INTO creator_retrieval_event
+      (task_id, query_text, criteria, local_hit_count, provider_hit_count, final_count)
+     VALUES ($1, $2, $3::jsonb, $4, $5, $6)`,
+    [
+      params.taskId || null,
+      params.queryText,
+      JSON.stringify(params.criteria || {}),
+      params.localHitCount,
+      params.providerHitCount,
+      params.finalCount,
+    ],
+  );
+}
+
+async function upsertCleanCreators(creators: ParsedCreator[]): Promise<void> {
+  const client = await pool.connect();
+  try {
+    await client.query('BEGIN');
+    for (const creator of creators) {
+      const embeddingText = creator.embeddingText || buildCreatorEmbeddingText(creator);
+      await client.query(
+        `INSERT INTO creator_profile_clean (
+          platform, platform_user_id, display_name, gender, fans_count, liked_collect_count,
+          content_type, content_tags, persona_tags, city, geo_location, profile_url,
+          cooperation_method, image_price, video_price, min_price, cooperation_status,
+          source_kind, source_provider, source_file, source_confidence, updated_at,
+          embedding_text, embedding
+        ) VALUES (
+          $1, $2, $3, $4, $5, $6,
+          $7, $8::jsonb, $9::jsonb, $10, $11, $12,
+          $13, $14, $15, $16, $17,
+          $18, $19, $20, $21, now(),
+          $22, $23::double precision[]
+        )
+        ON CONFLICT (platform, platform_user_id) DO UPDATE SET
+          display_name = EXCLUDED.display_name,
+          gender = EXCLUDED.gender,
+          fans_count = EXCLUDED.fans_count,
+          liked_collect_count = EXCLUDED.liked_collect_count,
+          content_type = EXCLUDED.content_type,
+          content_tags = EXCLUDED.content_tags,
+          persona_tags = EXCLUDED.persona_tags,
+          city = EXCLUDED.city,
+          geo_location = EXCLUDED.geo_location,
+          profile_url = EXCLUDED.profile_url,
+          cooperation_method = EXCLUDED.cooperation_method,
+          image_price = EXCLUDED.image_price,
+          video_price = EXCLUDED.video_price,
+          min_price = EXCLUDED.min_price,
+          cooperation_status = EXCLUDED.cooperation_status,
+          source_kind = EXCLUDED.source_kind,
+          source_provider = EXCLUDED.source_provider,
+          source_file = EXCLUDED.source_file,
+          source_confidence = EXCLUDED.source_confidence,
+          updated_at = now(),
+          embedding_text = EXCLUDED.embedding_text,
+          embedding = EXCLUDED.embedding`,
+        [
+          creator.platform,
+          creator.userId,
+          creator.nickname,
+          creator.gender || null,
+          creator.fansCount || 0,
+          creator.likedCollectCount || 0,
+          creator.contentType || creator.contentTags.join('、'),
+          JSON.stringify(creator.contentTags || []),
+          JSON.stringify(creator.personalTags || []),
+          creator.city || creator.location || null,
+          creator.geoLocation || creator.location || null,
+          creator.xiaohongshuUrl || null,
+          creator.cooperationMethod || null,
+          creator.imagePrice || 0,
+          creator.videoPrice || 0,
+          creator.minPrice || 0,
+          creator.cooperationStatus || null,
+          creator.sourceKind,
+          creator.sourceProvider || 'local-upload',
+          creator.sourceFile || null,
+          creator.sourceConfidence || 90,
+          embeddingText,
+          buildEmbedding(embeddingText),
+        ],
+      );
+    }
+    await client.query('COMMIT');
+  } catch (error) {
+    await client.query('ROLLBACK');
+    throw error;
+  } finally {
+    client.release();
+  }
+}
+
+function parseCreatorRows(rows: string[][], sourceFile: string): ParsedCreator[] {
+  const headerIndex = rows.findIndex((row) => row.some((cell) => normalizeHeader(cell) === '昵称'));
+  if (headerIndex < 0) return [];
+
+  const headers = rows[headerIndex].map(normalizeHeader);
+  const getIndex = (...names: string[]) => headers.findIndex((header) => names.includes(header));
+  const indexMap = {
+    nickname: getIndex('昵称', '账号名称'),
+    gender: getIndex('性别'),
+    fansWan: getIndex('粉丝数(万)', '粉丝数'),
+    likedWan: getIndex('赞藏数(万)', '赞藏数'),
+    contentType: getIndex('内容类型'),
+    city: getIndex('城市'),
+    geoLocation: getIndex('地理位置'),
+    profileUrl: getIndex('小红书主页', '主页链接'),
+    cooperationMethod: getIndex('合作方式'),
+    imagePrice: headers.findIndex((header) => header.includes('图文') && header.includes('报价')),
+    videoPrice: headers.findIndex((header) => header.includes('视频') && header.includes('报价')),
+  };
+
+  if (indexMap.nickname < 0 || indexMap.profileUrl < 0) return [];
+
+  return rows.slice(headerIndex + 1)
+    .filter((row) => row[indexMap.nickname])
+    .map((row) => {
+      const nickname = row[indexMap.nickname] || '';
+      const profileUrl = row[indexMap.profileUrl] || '';
+      const userId = extractProfileId(profileUrl) || hashText(`${sourceFile}:${nickname}:${profileUrl}`);
+      const contentType = row[indexMap.contentType] || '';
+      const imagePrice = toNumber(row[indexMap.imagePrice]);
+      const videoPrice = toNumber(row[indexMap.videoPrice]);
+      const fansCount = toCountFromWan(row[indexMap.fansWan]);
+      const likedCollectCount = toCountFromWan(row[indexMap.likedWan]);
+      const city = row[indexMap.city] || '';
+      const geoLocation = row[indexMap.geoLocation] || '';
+      const contentTags = splitTags(contentType);
+      const creator: ParsedCreator = {
+        userId,
+        platform: 'xiaohongshu',
+        nickname,
+        redId: userId,
+        location: city || geoLocation,
+        fansCount,
+        imagePrice,
+        videoPrice,
+        minPrice: Math.min(...[imagePrice, videoPrice].filter((price) => price > 0)) || 0,
+        cooperationStatus: row[indexMap.cooperationMethod] ? 'active' : '',
+        personalTags: [],
+        contentTags,
+        sourceProvider: 'local-upload',
+        sourceKind: 'uploaded',
+        sourceFile,
+        gender: row[indexMap.gender] || '',
+        likedCollectCount,
+        contentType,
+        city,
+        geoLocation,
+        xiaohongshuUrl: profileUrl,
+        cooperationMethod: row[indexMap.cooperationMethod] || '',
+        embeddingText: '',
+      };
+      creator.embeddingText = buildCreatorEmbeddingText(creator);
+      return creator;
+    });
+}
+
+function rowToCreator(row: Record<string, unknown>): JustOneCreator {
+  return {
+    userId: String(row.platform_user_id || ''),
+    platform: String(row.platform || ''),
+    nickname: String(row.display_name || ''),
+    redId: String(row.platform_user_id || ''),
+    location: String(row.city || row.geo_location || ''),
+    fansCount: Number(row.fans_count || 0),
+    imagePrice: Number(row.image_price || 0),
+    videoPrice: Number(row.video_price || 0),
+    minPrice: Number(row.min_price || 0),
+    cooperationStatus: String(row.cooperation_status || ''),
+    personalTags: Array.isArray(row.persona_tags) ? row.persona_tags as string[] : [],
+    contentTags: Array.isArray(row.content_tags) ? row.content_tags as string[] : [],
+    sourceProvider: String(row.source_provider || 'local-db'),
+    gender: String(row.gender || ''),
+    likedCollectCount: Number(row.liked_collect_count || 0),
+    contentType: String(row.content_type || ''),
+    city: String(row.city || ''),
+    geoLocation: String(row.geo_location || ''),
+    xiaohongshuUrl: String(row.profile_url || ''),
+    cooperationMethod: String(row.cooperation_method || ''),
+  };
+}
+
+function scoreRetrievedCreator(row: Record<string, unknown>, criteria: SearchCriteria): number {
+  let score = Number(row.similarity || 0) * 70;
+  const text = String(row.embedding_text || '').toLowerCase();
+  const keywords = [...(criteria.contentTags || []), ...(criteria.keywords || [])].map((item) => item.toLowerCase());
+  const regions = normalizeRegions(criteria.region);
+
+  for (const keyword of keywords) {
+    if (keyword && text.includes(keyword)) score += 12;
+  }
+  for (const region of regions) {
+    if (region && text.includes(region.toLowerCase())) score += 10;
+  }
+  for (const exclude of criteria.excludeTags || []) {
+    if (exclude && text.includes(exclude.toLowerCase())) score -= 4;
+  }
+  if (String(row.cooperation_method || '').includes('视频')) score += 8;
+  if (Number(row.video_price || 0) <= (criteria.budgetRange?.max || Infinity)) score += 6;
+  return score;
+}
+
+function buildCriteriaEmbeddingText(criteria: SearchCriteria): string {
+  return [
+    ...(criteria.keywords || []),
+    ...(criteria.contentTags || []),
+    ...normalizeRegions(criteria.region),
+    criteria.gender || '',
+    `粉丝${criteria.fanRange?.min || 0}-${criteria.fanRange?.max || 0}`,
+    `预算${criteria.budgetRange?.min || 0}-${criteria.budgetRange?.max || 0}`,
+  ].filter(Boolean).join(' ');
+}
+
+function buildCreatorEmbeddingText(creator: JustOneCreator): string {
+  return [
+    creator.nickname,
+    creator.contentType,
+    ...(creator.contentTags || []),
+    ...(creator.personalTags || []),
+    creator.city,
+    creator.geoLocation,
+    creator.gender,
+    creator.cooperationMethod,
+    `粉丝${creator.fansCount}`,
+    `图文报价${creator.imagePrice}`,
+    `视频报价${creator.videoPrice}`,
+  ].filter(Boolean).join(' ');
+}
+
+function buildEmbedding(text: string): number[] {
+  const vector = new Array(EMBEDDING_DIMENSION).fill(0);
+  const normalized = text.toLowerCase().replace(/\s+/g, ' ').trim();
+  const grams = new Set<string>();
+
+  for (const token of normalized.split(/[ ,,、/|]+/).filter(Boolean)) {
+    grams.add(token);
+    for (let size = 2; size <= 4; size++) {
+      for (let i = 0; i <= token.length - size; i++) {
+        grams.add(token.slice(i, i + size));
+      }
+    }
+  }
+
+  for (const gram of grams) {
+    const index = stableHash(gram) % EMBEDDING_DIMENSION;
+    vector[index] += 1;
+  }
+
+  const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
+  return norm > 0 ? vector.map((value) => Number((value / norm).toFixed(8))) : vector;
+}
+
+function normalizeCriteria(criteria: SearchCriteria): SearchCriteria {
+  const maxFans = Number(criteria.fanRange?.max || 0);
+  return {
+    ...criteria,
+    fanRange: maxFans > 0 && maxFans <= 1000
+      ? { min: Number(criteria.fanRange?.min || 0) * 10000, max: maxFans * 10000 }
+      : criteria.fanRange,
+  };
+}
+
+function normalizeHeader(value: string): string {
+  return String(value || '').replace(/\s+/g, '').replace(/&#10;/g, '').trim();
+}
+
+function normalizeRegions(region?: string | string[]): string[] {
+  if (!region) return [];
+  return (Array.isArray(region) ? region : String(region).split(/[、,,/]/))
+    .map((item) => item.trim())
+    .filter(Boolean);
+}
+
+function mapPlatform(platform: string): string {
+  if (platform === '小红书') return 'xiaohongshu';
+  if (platform === '抖音') return 'douyin';
+  return platform;
+}
+
+function splitTags(value: string): string[] {
+  return String(value || '').split(/[、,,/]/).map((item) => item.trim()).filter(Boolean);
+}
+
+function extractProfileId(url: string): string {
+  return String(url || '').split('/').filter(Boolean).pop() || '';
+}
+
+function toNumber(value: unknown): number {
+  if (typeof value === 'number') return value;
+  const text = String(value || '').replace(/[¥,,]/g, '').trim();
+  if (!text || text === '/') return 0;
+  return Number(text) || 0;
+}
+
+function toCountFromWan(value: unknown): number {
+  const num = toNumber(value);
+  return num > 0 && num < 10000 ? Math.round(num * 10000) : Math.round(num);
+}
+
+function hashText(text: string): string {
+  return stableHash(text).toString(36);
+}
+
+function stableHash(text: string): number {
+  let hash = 2166136261;
+  for (let i = 0; i < text.length; i++) {
+    hash ^= text.charCodeAt(i);
+    hash = Math.imul(hash, 16777619);
+  }
+  return Math.abs(hash >>> 0);
+}

+ 123 - 45
server/services/recommendation.service.ts

@@ -1,6 +1,7 @@
 import { config } from '../config.ts';
 import { searchCreators, type JustOneCreator } from './justone.service.ts';
-import { getUserNotes, type ContentSample } from './tikhub.service.ts';
+import type { ContentSample } from './tikhub.service.ts';
+import { searchLocalCreators } from './local-creator-db.service.ts';
 
 export interface NormalizedCandidate {
   id: string;
@@ -12,6 +13,13 @@ export interface NormalizedCandidate {
   fansCount: number;
   contentTags: string[];
   personaTags: string[];
+  gender?: string;
+  likedCollectCount?: number;
+  contentType?: string;
+  city?: string;
+  geoLocation?: string;
+  xiaohongshuUrl?: string;
+  cooperationMethod?: string;
   imagePrice: number;
   videoPrice: number;
   minPrice: number;
@@ -31,7 +39,7 @@ interface SearchCriteria {
   keywords: string[];
   fanRange: { min: number; max: number };
   budgetRange: { min: number; max: number };
-  region?: string;
+  region?: string | string[];
   gender?: string;
   contentTags?: string[];
   excludeTags?: string[];
@@ -53,6 +61,9 @@ const DEFAULT_WEIGHTS: ScoringWeights = {
   cooperationReady: 15,
 };
 
+const MAX_KEYWORDS_PER_PLATFORM = 1;
+const PAGES_PER_PLATFORM = 3;
+
 /**
  * 主推荐流程:根据搜索条件从多个来源召回候选人并评分排序
  */
@@ -60,40 +71,54 @@ export async function generateRecommendations(
   criteria: SearchCriteria,
   onProgress?: (stage: string, detail: string) => void
 ): Promise<NormalizedCandidate[]> {
+  criteria = normalizeSearchCriteria(criteria);
   console.log('[Recommend] ========== 开始推荐流程 ==========');
   console.log('[Recommend] 搜索条件:', JSON.stringify(criteria, null, 2));
   onProgress?.('search', '正在从第三方平台召回候选达人...');
 
-  // Step 1: 从 JustOne API 召回候选
+  // Step 1: 优先从本地/自有达人库召回候选
   const allCandidates: JustOneCreator[] = [];
+  const localCandidates = await searchLocalCreators(criteria, config.recommendation.maxCandidates);
+  if (localCandidates.length > 0) {
+    console.log(`[Recommend] 本地达人库命中: ${localCandidates.length} 位`);
+    onProgress?.('search', `本地达人库命中 ${localCandidates.length} 位候选达人`);
+    allCandidates.push(...localCandidates);
+  }
 
   // JustOne API 仅支持小红书和抖音
   const supportedPlatforms = ['xiaohongshu', '小红书', 'douyin', '抖音'];
 
-  for (const platform of criteria.platforms) {
-    const platformName = mapPlatformName(platform);
-    if (!supportedPlatforms.includes(platform) && !supportedPlatforms.includes(platformName)) {
-      console.log(`[Recommend] 跳过不支持的平台: ${platform} (${platformName})`);
-      continue;
-    }
-    for (const keyword of criteria.keywords) {
-      console.log(`[Recommend] 搜索: 平台=${platform} -> ${platformName}, 关键词=${keyword}`);
-      onProgress?.('search', `搜索 ${platformName} - ${keyword}...`);
-
-      const results = await searchCreators({
-        keyword,
-        platform: platformName,
-        minFans: criteria.fanRange.min,
-        maxFans: criteria.fanRange.max,
-        minPrice: criteria.budgetRange.min,
-        maxPrice: criteria.budgetRange.max,
-        gender: criteria.gender,
-        location: criteria.region,
-        pageSize: 50,
-      });
-
-      console.log(`[Recommend] ${platformName}/${keyword} 返回 ${results.length} 条结果`);
-      allCandidates.push(...results);
+  if (allCandidates.length === 0) {
+    const searchKeywords = buildCompactSearchKeywords(criteria);
+    for (const platform of uniquePlatforms(criteria.platforms)) {
+      const platformName = mapPlatformName(platform);
+      if (!supportedPlatforms.includes(platform) && !supportedPlatforms.includes(platformName)) {
+        console.log(`[Recommend] 跳过不支持的平台: ${platform} (${platformName})`);
+        continue;
+      }
+      for (const keyword of searchKeywords) {
+        for (let page = 1; page <= PAGES_PER_PLATFORM; page++) {
+          console.log(`[Recommend] 搜索: 平台=${platform} -> ${platformName}, 关键词=${keyword}, 页=${page}`);
+          onProgress?.('search', `搜索 ${platformName} - ${keyword} 第 ${page}/${PAGES_PER_PLATFORM} 页...`);
+
+          const results = await searchCreators({
+            keyword,
+            platform: platformName,
+            minFans: criteria.fanRange.min,
+            maxFans: criteria.fanRange.max,
+            minPrice: criteria.budgetRange.min,
+            maxPrice: criteria.budgetRange.max,
+            gender: criteria.gender,
+            location: Array.isArray(criteria.region) ? criteria.region.join(',') : criteria.region,
+            page,
+            pageSize: 50,
+          });
+
+          console.log(`[Recommend] ${platformName}/${keyword}/page-${page} 返回 ${results.length} 条结果`);
+          allCandidates.push(...results);
+          if (results.length === 0) break;
+        }
+      }
     }
   }
 
@@ -121,26 +146,12 @@ export async function generateRecommendations(
     });
   }
 
-  // Step 4: 内容采样验证(取 Top N 候选进行深度验证)
+  // Step 4: 使用 JustOne 搜索结果内置的标签、报价和近期内容进行验证
   const topCandidates = normalized
     .sort((a, b) => b.score - a.score)
     .slice(0, Math.min(config.recommendation.maxCandidates, normalized.length));
 
-  onProgress?.('processing', `对 Top ${Math.min(20, topCandidates.length)} 位候选进行内容采样验证...`);
-
-  // 对前20个进行内容采样
-  for (let i = 0; i < Math.min(20, topCandidates.length); i++) {
-    const candidate = topCandidates[i];
-    try {
-      const samples = await getUserNotes(candidate.platformUserId, candidate.platform, 10);
-      if (samples.length > 0) {
-        candidate.contentSamples = samples;
-        candidate.styleMatch = calculateStyleMatch(samples, criteria.keywords);
-      }
-    } catch {
-      // Content sampling is optional, continue
-    }
-  }
+  onProgress?.('processing', `已用 JustOne 内置字段完成 Top ${topCandidates.length} 位候选评分...`);
 
   // Step 5: 最终排序和状态标记
   onProgress?.('processing', '计算最终推荐排序...');
@@ -194,17 +205,25 @@ function normalizeCandidateFromJustOne(creator: JustOneCreator, criteria: Search
     fansCount: creator.fansCount,
     contentTags: creator.contentTags,
     personaTags: creator.personalTags,
+    gender: creator.gender,
+    likedCollectCount: creator.likedCollectCount,
+    contentType: creator.contentType || creator.contentTags.join('、'),
+    city: creator.city || creator.location,
+    geoLocation: creator.geoLocation || creator.location,
+    xiaohongshuUrl: creator.xiaohongshuUrl || (platform === 'xiaohongshu' ? profileUrl : ''),
+    cooperationMethod: creator.cooperationMethod,
     imagePrice: creator.imagePrice,
     videoPrice: creator.videoPrice,
     minPrice: creator.minPrice,
     cooperationStatus: creator.cooperationStatus,
-    sourceProvider: 'justone',
+    sourceProvider: creator.sourceProvider || 'justone',
     sourceConfidence: 80,
     score: totalScore,
     styleMatch: contentScore,
     recommendStatus: '需复核',
     recommendReason: '',
     riskNote: '',
+    contentSamples: creator.contentSamples,
   };
 }
 
@@ -270,8 +289,15 @@ function scoreContentRelevance(tags: string[], keywords: string[]): number {
   if (tags.length === 0 || keywords.length === 0) return 60;
   let matches = 0;
   for (const keyword of keywords) {
+    const keywordTerms = extractMatchTerms(keyword);
     for (const tag of tags) {
-      if (tag.includes(keyword) || keyword.includes(tag)) {
+      const tagTerms = extractMatchTerms(tag);
+      if (
+        tag.includes(keyword) ||
+        keyword.includes(tag) ||
+        keywordTerms.some((term) => tag.includes(term)) ||
+        tagTerms.some((term) => keyword.includes(term))
+      ) {
         matches++;
         break;
       }
@@ -281,6 +307,14 @@ function scoreContentRelevance(tags: string[], keywords: string[]): number {
   return Math.min(95, Math.round(60 + matchRatio * 50));
 }
 
+function extractMatchTerms(text: string): string[] {
+  const clean = text.trim();
+  const domainTerms = ['家居', '家装', '探店', '生活', '精致', '美食', '出行', '旅游', '母婴', '记录'];
+  const terms = domainTerms.filter((term) => clean.includes(term));
+  if (clean.length >= 2) terms.push(clean);
+  return [...new Set(terms)];
+}
+
 function calculateStyleMatch(samples: ContentSample[], keywords: string[]): number {
   if (samples.length === 0 || keywords.length === 0) return 60;
 
@@ -300,6 +334,50 @@ function calculateStyleMatch(samples: ContentSample[], keywords: string[]): numb
   return Math.min(95, Math.round(60 + matchRatio * 45 + engagementRatio * 5));
 }
 
+function buildCompactSearchKeywords(criteria: SearchCriteria): string[] {
+  const candidates = [...(criteria.contentTags || []), ...criteria.keywords]
+    .map((keyword) => keyword.trim())
+    .filter(Boolean);
+  const unique = [...new Set(candidates)];
+  return unique.slice(0, MAX_KEYWORDS_PER_PLATFORM).length > 0
+    ? unique.slice(0, MAX_KEYWORDS_PER_PLATFORM)
+    : ['生活方式'];
+}
+
+function normalizeSearchCriteria(criteria: SearchCriteria): SearchCriteria {
+  return {
+    ...criteria,
+    fanRange: normalizeFanRange(criteria.fanRange),
+    budgetRange: normalizeBudgetRange(criteria.budgetRange),
+    platforms: criteria.platforms?.length ? criteria.platforms : ['xiaohongshu'],
+    keywords: criteria.keywords?.length ? criteria.keywords : ['生活方式'],
+  };
+}
+
+function normalizeFanRange(range: { min: number; max: number }): { min: number; max: number } {
+  const min = Number(range?.min || 0);
+  const max = Number(range?.max || 0);
+
+  // LLM 容易把“1万-15万”解析成 1-15;统一换算成真实粉丝数。
+  if (max > 0 && max <= 1000) {
+    return { min: Math.max(0, min * 10000), max: max * 10000 };
+  }
+  return { min, max };
+}
+
+function normalizeBudgetRange(range: { min: number; max: number }): { min: number; max: number } {
+  return {
+    min: Number(range?.min || 0),
+    max: Number(range?.max || 0),
+  };
+}
+
+function uniquePlatforms(platforms: string[]): string[] {
+  const normalized = platforms.map(mapPlatformName).filter(Boolean);
+  const supported = normalized.filter((platform) => platform === 'xiaohongshu' || platform === 'douyin');
+  return [...new Set(supported.length > 0 ? supported : ['xiaohongshu'])];
+}
+
 function mapPlatformName(platform: string): string {
   const map: Record<string, string> = {
     xiaohongshu: 'xiaohongshu',

+ 178 - 17
server/utils/file-parser.ts

@@ -1,3 +1,5 @@
+import { inflateRawSync } from 'node:zlib';
+
 /**
  * 简易文件解析工具
  * 支持从上传的 Excel/Word 文件中提取文本内容
@@ -28,14 +30,34 @@ export function extractTextFromBuffer(buffer: Buffer, fileName: string): string
   return result;
 }
 
+export interface WorkbookSheetTable {
+  name: string;
+  rows: string[][];
+}
+
+export function extractWorkbookTablesFromBuffer(buffer: Buffer): WorkbookSheetTable[] {
+  const entries = listZipEntries(buffer);
+  if (entries.length === 0) return [];
+
+  const sharedStrings = parseSharedStrings(readZipEntry(buffer, 'xl/sharedStrings.xml'));
+  const sheetNames = parseWorkbookSheetNames(readZipEntry(buffer, 'xl/workbook.xml'));
+  return entries
+    .filter((entry) => /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name))
+    .sort((a, b) => a.name.localeCompare(b.name))
+    .map((entry, index) => ({
+      name: sheetNames[index] || `Sheet${index + 1}`,
+      rows: parseWorksheetRows(readZipEntry(buffer, entry.name), sharedStrings),
+    }))
+    .filter((sheet) => sheet.rows.length > 0);
+}
+
 /**
  * 从 .docx (ZIP 内的 XML) 提取纯文本
  * 简易实现:查找 <w:t> 标签内容
  */
 function extractDocxText(buffer: Buffer): string {
-  // .docx is a ZIP file; for now, do regex extraction on raw buffer
-  // A production implementation would use a proper ZIP + XML parser
-  const raw = buffer.toString('utf-8');
+  const documentXml = readZipEntry(buffer, 'word/document.xml');
+  const raw = documentXml ? documentXml.toString('utf-8') : buffer.toString('utf-8');
   const textParts: string[] = [];
 
   // Extract text between <w:t> tags
@@ -59,23 +81,21 @@ function extractDocxText(buffer: Buffer): string {
  * 从 .xlsx (ZIP 内的 sharedStrings.xml) 提取文本
  */
 function extractXlsxText(buffer: Buffer): string {
-  const raw = buffer.toString('utf-8');
-  const textParts: string[] = [];
-
-  // Extract text from <t> tags in shared strings
-  const regex = /<t[^>]*>([^<]*)<\/t>/g;
-  let match: RegExpExecArray | null;
-  while ((match = regex.exec(raw)) !== null) {
-    if (match[1] && match[1].trim()) {
-      textParts.push(match[1].trim());
-    }
+  const entries = listZipEntries(buffer);
+  if (entries.length === 0) {
+    return extractReadableText(buffer);
   }
 
-  if (textParts.length > 0) {
-    return textParts.join(' | ');
-  }
+  const sharedStrings = parseSharedStrings(readZipEntry(buffer, 'xl/sharedStrings.xml'));
+  const sheetEntries = entries
+    .filter((entry) => /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name))
+    .sort((a, b) => a.name.localeCompare(b.name));
 
-  return extractReadableText(buffer);
+  const sheetTexts = sheetEntries
+    .map((entry, index) => parseWorksheetText(readZipEntry(buffer, entry.name), sharedStrings, index + 1))
+    .filter(Boolean);
+
+  return sheetTexts.length > 0 ? sheetTexts.join('\n\n') : extractReadableText(buffer);
 }
 
 /**
@@ -88,3 +108,144 @@ function extractReadableText(buffer: Buffer): string {
   // Collapse whitespace
   return readable.replace(/\s+/g, ' ').trim().slice(0, 30000);
 }
+
+interface ZipEntry {
+  name: string;
+  method: number;
+  compressedSize: number;
+  localHeaderOffset: number;
+}
+
+function listZipEntries(buffer: Buffer): ZipEntry[] {
+  const entries: ZipEntry[] = [];
+  let offset = 0;
+  while (offset < buffer.length - 46) {
+    if (buffer.readUInt32LE(offset) !== 0x02014b50) {
+      offset += 1;
+      continue;
+    }
+
+    const method = buffer.readUInt16LE(offset + 10);
+    const compressedSize = buffer.readUInt32LE(offset + 20);
+    const fileNameLength = buffer.readUInt16LE(offset + 28);
+    const extraLength = buffer.readUInt16LE(offset + 30);
+    const commentLength = buffer.readUInt16LE(offset + 32);
+    const localHeaderOffset = buffer.readUInt32LE(offset + 42);
+    const name = buffer.toString('utf-8', offset + 46, offset + 46 + fileNameLength);
+
+    entries.push({ name, method, compressedSize, localHeaderOffset });
+    offset += 46 + fileNameLength + extraLength + commentLength;
+  }
+  return entries;
+}
+
+function readZipEntry(buffer: Buffer, entryName: string): Buffer | null {
+  const entry = listZipEntries(buffer).find((item) => item.name === entryName);
+  if (!entry) return null;
+
+  const localOffset = entry.localHeaderOffset;
+  if (buffer.readUInt32LE(localOffset) !== 0x04034b50) return null;
+
+  const fileNameLength = buffer.readUInt16LE(localOffset + 26);
+  const extraLength = buffer.readUInt16LE(localOffset + 28);
+  const dataStart = localOffset + 30 + fileNameLength + extraLength;
+  const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize);
+
+  if (entry.method === 0) return compressed;
+  if (entry.method === 8) return inflateRawSync(compressed);
+  return null;
+}
+
+function parseSharedStrings(xmlBuffer: Buffer | null): string[] {
+  if (!xmlBuffer) return [];
+  const xml = xmlBuffer.toString('utf-8');
+  const strings: string[] = [];
+  const itemRegex = /<si\b[^>]*>([\s\S]*?)<\/si>/g;
+  let itemMatch: RegExpExecArray | null;
+  while ((itemMatch = itemRegex.exec(xml)) !== null) {
+    strings.push(extractXmlText(itemMatch[1]));
+  }
+  return strings;
+}
+
+function parseWorksheetText(xmlBuffer: Buffer | null, sharedStrings: string[], sheetIndex: number): string {
+  const rows = parseWorksheetRows(xmlBuffer, sharedStrings)
+    .map((row) => row.map((value) => value.trim()).filter(Boolean));
+  const textRows = rows.filter((row) => row.length > 0).map((row) => row.join(' | '));
+  return textRows.length > 0 ? `Sheet${sheetIndex}\n${textRows.join('\n')}` : '';
+}
+
+function parseWorksheetRows(xmlBuffer: Buffer | null, sharedStrings: string[]): string[][] {
+  if (!xmlBuffer) return [];
+  const xml = xmlBuffer.toString('utf-8');
+  const rows: string[][] = [];
+  const rowRegex = /<row\b[^>]*>([\s\S]*?)<\/row>/g;
+  let rowMatch: RegExpExecArray | null;
+
+  while ((rowMatch = rowRegex.exec(xml)) !== null) {
+    const values: string[] = [];
+    const cellRegex = /<c\b([^>]*)>([\s\S]*?)<\/c>/g;
+    let cellMatch: RegExpExecArray | null;
+    while ((cellMatch = cellRegex.exec(rowMatch[1])) !== null) {
+      const attrs = cellMatch[1];
+      const body = cellMatch[2];
+      const ref = attrs.match(/\br="([A-Z]+\d+)"/)?.[1] || '';
+      const colIndex = ref ? columnNameToIndex(ref.replace(/\d+/g, '')) : values.length;
+      const type = attrs.match(/\bt="([^"]+)"/)?.[1];
+      const rawValue = body.match(/<v[^>]*>([\s\S]*?)<\/v>/)?.[1] || '';
+      let value = '';
+
+      if (type === 's') {
+        value = sharedStrings[Number(rawValue)] || '';
+      } else if (type === 'inlineStr') {
+        value = extractXmlText(body);
+      } else {
+        value = decodeXml(rawValue);
+      }
+
+      values[colIndex] = value.trim();
+    }
+    if (values.some(Boolean)) rows.push(values.map((value) => value || ''));
+  }
+
+  return rows;
+}
+
+function parseWorkbookSheetNames(xmlBuffer: Buffer | null): string[] {
+  if (!xmlBuffer) return [];
+  const xml = xmlBuffer.toString('utf-8');
+  const names: string[] = [];
+  const sheetRegex = /<sheet\b[^>]*\bname="([^"]+)"/g;
+  let match: RegExpExecArray | null;
+  while ((match = sheetRegex.exec(xml)) !== null) {
+    names.push(decodeXml(match[1]));
+  }
+  return names;
+}
+
+function columnNameToIndex(name: string): number {
+  let result = 0;
+  for (const char of name) {
+    result = result * 26 + (char.charCodeAt(0) - 64);
+  }
+  return Math.max(0, result - 1);
+}
+
+function extractXmlText(xml: string): string {
+  const parts: string[] = [];
+  const textRegex = /<t\b[^>]*>([\s\S]*?)<\/t>/g;
+  let textMatch: RegExpExecArray | null;
+  while ((textMatch = textRegex.exec(xml)) !== null) {
+    parts.push(decodeXml(textMatch[1]));
+  }
+  return parts.join('').trim();
+}
+
+function decodeXml(value: string): string {
+  return value
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"')
+    .replace(/&apos;/g, "'")
+    .replace(/&amp;/g, '&');
+}

+ 13 - 0
src/modules/workspace/components/workspace-flow/workspace-flow.component.html

@@ -6,6 +6,19 @@
 
   <!-- Step 1: Upload Brief -->
   <div class="flow-section" *ngIf="!isProcessing && !isCompleted">
+    <div class="library-upload-panel">
+      <div>
+        <h3>本地达人资料库</h3>
+        <p>先上传公司已有达人表,系统会写入 PostgreSQL,并优先用于后续提号。</p>
+        <small *ngIf="libraryMessage">{{ libraryMessage }}</small>
+      </div>
+      <label class="secondary-upload-button">
+        <input type="file" accept=".xlsx,.xls" (change)="onCreatorLibraryInput($event)" hidden />
+        <span *ngIf="!libraryUploading">上传达人资料库</span>
+        <span *ngIf="libraryUploading">入库中...</span>
+      </label>
+    </div>
+
     <app-brief-upload (fileSelected)="onFileSelected($event)"></app-brief-upload>
   </div>
 

+ 54 - 0
src/modules/workspace/components/workspace-flow/workspace-flow.component.scss

@@ -26,6 +26,60 @@
   margin-bottom: 2rem;
 }
 
+.library-upload-panel {
+  border: 1px solid #d9e2dd;
+  border-radius: 8px;
+  padding: 1rem 1.25rem;
+  margin-bottom: 1rem;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 1rem;
+  background: #f7fbf9;
+
+  h3 {
+    margin: 0 0 0.25rem;
+    font-size: 1rem;
+    color: #1a1a2e;
+  }
+
+  p {
+    margin: 0;
+    color: #5f6b66;
+    font-size: 0.86rem;
+  }
+
+  small {
+    display: block;
+    margin-top: 0.35rem;
+    color: #1f8a70;
+    font-weight: 600;
+  }
+}
+
+.secondary-upload-button {
+  flex: 0 0 auto;
+  border: 1px solid #1f8a70;
+  border-radius: 6px;
+  padding: 0.6rem 0.9rem;
+  color: #0f6f59;
+  background: #fff;
+  font-weight: 700;
+  font-size: 0.86rem;
+  cursor: pointer;
+}
+
+@media (max-width: 640px) {
+  .library-upload-panel {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .secondary-upload-button {
+    text-align: center;
+  }
+}
+
 .error-message {
   background: #fff5f5;
   border: 1px solid #fcc;

+ 21 - 0
src/modules/workspace/components/workspace-flow/workspace-flow.component.ts

@@ -20,6 +20,8 @@ export class WorkspaceFlowComponent {
   candidateCount = 0;
   exporting = false;
   errorMessage = '';
+  libraryUploading = false;
+  libraryMessage = '';
   stopPolling: (() => void) | null = null;
 
   constructor(private api: WorkspaceApiService) {}
@@ -58,6 +60,25 @@ export class WorkspaceFlowComponent {
     }
   }
 
+  async onCreatorLibraryInput(event: Event): Promise<void> {
+    const input = event.target as HTMLInputElement;
+    const file = input.files?.[0];
+    if (!file) return;
+
+    this.libraryUploading = true;
+    this.libraryMessage = '';
+    this.errorMessage = '';
+    try {
+      const result = await this.api.uploadCreatorLibrary(file);
+      this.libraryMessage = `已入库 ${result.localCreatorCount} 位达人:${result.fileName}`;
+    } catch (error: unknown) {
+      this.errorMessage = error instanceof Error ? error.message : '达人资料库上传失败';
+    } finally {
+      this.libraryUploading = false;
+      input.value = '';
+    }
+  }
+
   private startPolling(taskId: string): void {
     if (this.stopPolling) {
       this.stopPolling();

+ 8 - 0
src/modules/workspace/models/task.model.ts

@@ -37,6 +37,13 @@ export interface CandidateCreator {
   fansCount: number;
   contentTags: string[];
   personaTags: string[];
+  gender?: string;
+  likedCollectCount?: number;
+  contentType?: string;
+  city?: string;
+  geoLocation?: string;
+  xiaohongshuUrl?: string;
+  cooperationMethod?: string;
   imagePrice: number;
   videoPrice: number;
   minPrice: number;
@@ -71,6 +78,7 @@ export interface UploadBriefResponse {
   taskId: string;
   fileName: string;
   message: string;
+  localCreatorCount?: number;
 }
 
 export interface TaskProgressEvent {

+ 16 - 0
src/modules/workspace/services/workspace-api.service.ts

@@ -21,6 +21,22 @@ export class WorkspaceApiService {
     return response.json();
   }
 
+  async uploadCreatorLibrary(file: File): Promise<{ ok: boolean; fileName: string; localCreatorCount: number; message: string }> {
+    const formData = new FormData();
+    formData.append('file', file);
+
+    const response = await fetch(`${this.baseUrl}/api/creator-library/upload`, {
+      method: 'POST',
+      body: formData,
+    });
+
+    if (!response.ok) {
+      throw new Error(`达人资料库上传失败: ${response.statusText}`);
+    }
+
+    return response.json();
+  }
+
   async getTaskStatus(taskId: string): Promise<TaskResult> {
     const response = await fetch(`${this.baseUrl}/api/tasks/${taskId}`);