From 36948c98ca4a53eed21c4022f19cd84753914cc1 Mon Sep 17 00:00:00 2001 From: lunanting Date: Sat, 12 Sep 2026 17:50:26 +0800 Subject: [PATCH] refactor: remove store and operations feature suites --- README.md | 6 +- backend/src/api/admin.js | 152 +- backend/src/api/aiBuyerShow.js | 397 ---- backend/src/api/csQuality.js | 93 - backend/src/api/desktopClient.js | 93 - backend/src/api/pricingCalculator.js | 833 ------- backend/src/api/pricingCalculatorExternal.js | 73 - backend/src/api/productAnalysis.js | 2 +- backend/src/api/storeCheckup.js | 999 --------- backend/src/constants/sidebarPages.js | 5 - backend/src/index.js | 10 - .../src/services/aiBuyerShowReviewService.js | 375 ---- .../services/aiBuyerShowSeedPackService.js | 237 -- backend/src/services/aiBuyerShowService.js | 1132 ---------- backend/src/services/csQualityAiPrompt.js | 111 - backend/src/services/csQualityService.js | 1967 ----------------- backend/src/services/desktopReleaseService.js | 254 --- backend/src/services/desktopSessionService.js | 3 +- backend/src/services/localSchema.js | 35 +- backend/src/services/retiredFeatures.js | 34 +- backend/test/csQualityService.test.js | 255 --- backend/test/desktopReleaseService.test.js | 60 - backend/test/desktopSessionService.test.js | 1 - .../test/externalAutomationSharedAuth.test.js | 52 - .../test/pricingCalculatorSelection.test.js | 110 - backend/test/retiredFeatures.test.js | 19 +- frontend/src/api/aiBuyerShow.js | 74 - frontend/src/api/csQuality.js | 44 - frontend/src/api/desktopClient.js | 34 - frontend/src/api/pricingCalculator.js | 79 - frontend/src/api/storeCheckup.js | 23 - frontend/src/config/sidebarNavigation.js | 18 - frontend/src/layouts/BasicLayout.jsx | 24 - frontend/src/pages/AiBuyerShow/index.jsx | 1093 --------- frontend/src/pages/AiBuyerShow/style.css | 688 ------ .../AiQualityDrawer.jsx | 378 ---- .../AiQualityHistoryDrawer.jsx | 160 -- .../AiQualityResult.jsx | 516 ----- .../pages/CustomerServiceQuality/index.jsx | 634 ------ .../pages/CustomerServiceQuality/style.css | 872 -------- .../src/pages/DesktopClientDownload/index.jsx | 105 - .../src/pages/DesktopClientDownload/style.css | 112 - .../src/pages/DesktopClientReleases/index.jsx | 261 --- .../src/pages/DesktopClientReleases/style.css | 299 --- .../pages/DevelopmentStylePreview/index.jsx | 422 ---- .../DevelopmentStylePreview/stylePreview.css | 992 +-------- .../PricingCalculator/DefaultsConfigModal.jsx | 75 - .../PricingCalculator/DropshipCalculator.jsx | 909 -------- .../PricingCalculator/FieldConfigDrawer.jsx | 90 - .../pages/PricingCalculator/HistoryDrawer.jsx | 177 -- frontend/src/pages/PricingCalculator/calc.js | 80 - .../src/pages/PricingCalculator/constants.js | 136 -- .../src/pages/PricingCalculator/dropship.js | 176 -- .../PricingCalculator/dropshipFieldConfig.js | 72 - .../pages/PricingCalculator/fieldConfig.js | 93 - .../src/pages/PricingCalculator/index.jsx | 1329 ----------- .../src/pages/PricingCalculator/inputs.jsx | 46 - .../src/pages/PricingCalculator/style.css | 774 ------- .../src/pages/Settings/DatabaseSettings.jsx | 2 - frontend/src/pages/StoreCheckup/index.jsx | 591 ----- frontend/src/pages/StoreCheckup/style.css | 387 ---- frontend/src/router/index.jsx | 38 +- .../test/pricingCalculatorDropship.test.mjs | 130 -- frontend/test/retiredNavigation.test.mjs | 9 +- sql/create_pdd_seed_resource_packs.sql | 28 - ...ricing_calculator_history_system_mysql.sql | 15 - ...ing_calculator_user_prefs_system_mysql.sql | 8 - ...oduct_analysis_user_prefs_system_mysql.sql | 7 + 68 files changed, 123 insertions(+), 19185 deletions(-) delete mode 100644 backend/src/api/aiBuyerShow.js delete mode 100644 backend/src/api/csQuality.js delete mode 100644 backend/src/api/pricingCalculator.js delete mode 100644 backend/src/api/pricingCalculatorExternal.js delete mode 100644 backend/src/api/storeCheckup.js delete mode 100644 backend/src/services/aiBuyerShowReviewService.js delete mode 100644 backend/src/services/aiBuyerShowSeedPackService.js delete mode 100644 backend/src/services/aiBuyerShowService.js delete mode 100644 backend/src/services/csQualityAiPrompt.js delete mode 100644 backend/src/services/csQualityService.js delete mode 100644 backend/src/services/desktopReleaseService.js delete mode 100644 backend/test/csQualityService.test.js delete mode 100644 backend/test/desktopReleaseService.test.js delete mode 100644 backend/test/pricingCalculatorSelection.test.js delete mode 100644 frontend/src/api/aiBuyerShow.js delete mode 100644 frontend/src/api/csQuality.js delete mode 100644 frontend/src/api/desktopClient.js delete mode 100644 frontend/src/api/pricingCalculator.js delete mode 100644 frontend/src/api/storeCheckup.js delete mode 100644 frontend/src/pages/AiBuyerShow/index.jsx delete mode 100644 frontend/src/pages/AiBuyerShow/style.css delete mode 100644 frontend/src/pages/CustomerServiceQuality/AiQualityDrawer.jsx delete mode 100644 frontend/src/pages/CustomerServiceQuality/AiQualityHistoryDrawer.jsx delete mode 100644 frontend/src/pages/CustomerServiceQuality/AiQualityResult.jsx delete mode 100644 frontend/src/pages/CustomerServiceQuality/index.jsx delete mode 100644 frontend/src/pages/CustomerServiceQuality/style.css delete mode 100644 frontend/src/pages/DesktopClientDownload/index.jsx delete mode 100644 frontend/src/pages/DesktopClientDownload/style.css delete mode 100644 frontend/src/pages/DesktopClientReleases/index.jsx delete mode 100644 frontend/src/pages/DesktopClientReleases/style.css delete mode 100644 frontend/src/pages/PricingCalculator/DefaultsConfigModal.jsx delete mode 100644 frontend/src/pages/PricingCalculator/DropshipCalculator.jsx delete mode 100644 frontend/src/pages/PricingCalculator/FieldConfigDrawer.jsx delete mode 100644 frontend/src/pages/PricingCalculator/HistoryDrawer.jsx delete mode 100644 frontend/src/pages/PricingCalculator/calc.js delete mode 100644 frontend/src/pages/PricingCalculator/constants.js delete mode 100644 frontend/src/pages/PricingCalculator/dropship.js delete mode 100644 frontend/src/pages/PricingCalculator/dropshipFieldConfig.js delete mode 100644 frontend/src/pages/PricingCalculator/fieldConfig.js delete mode 100644 frontend/src/pages/PricingCalculator/index.jsx delete mode 100644 frontend/src/pages/PricingCalculator/inputs.jsx delete mode 100644 frontend/src/pages/PricingCalculator/style.css delete mode 100644 frontend/src/pages/StoreCheckup/index.jsx delete mode 100644 frontend/src/pages/StoreCheckup/style.css delete mode 100644 frontend/test/pricingCalculatorDropship.test.mjs delete mode 100644 sql/create_pdd_seed_resource_packs.sql delete mode 100644 sql/create_pricing_calculator_history_system_mysql.sql delete mode 100644 sql/create_pricing_calculator_user_prefs_system_mysql.sql create mode 100644 sql/create_product_analysis_user_prefs_system_mysql.sql diff --git a/README.md b/README.md index d09b4f5..c51db3c 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ 公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。 -保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、店铺体检、客服质检、上架测算、AI 买家秀和客户端管理等现有模块。部分菜单仍沿用原项目的占位页面。 +保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。 -已删除市场数据整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。 +已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。 ## 本地启动 @@ -27,7 +27,7 @@ npm --prefix frontend run check 前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。 -启动时会迁移旧侧栏配置,并删除已退役页面的权限和专属设置;业务数据及历史聊天记录不作数据库删表操作。迁移仅作用于实际配置给此实例的系统数据库。 +启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。上架测算旧偏好表中的商品分析阈值会先迁移到商品分析专用表;其他保留模块的数据不受影响。迁移仅作用于实际配置给此实例的系统数据库。 ## 参与修改 diff --git a/backend/src/api/admin.js b/backend/src/api/admin.js index 0afd40c..99e9adb 100644 --- a/backend/src/api/admin.js +++ b/backend/src/api/admin.js @@ -1,14 +1,14 @@ -const { isRetiredSystemConfigKey, pruneRetiredSidebarConfig } = require('../services/retiredFeatures'); +const { + isRetiredDatabaseConfigName, + isRetiredSystemConfigKey, + pruneRetiredSidebarConfig, +} = require('../services/retiredFeatures'); // 管理员 API const express = require('express'); const router = express.Router(); const bcrypt = require('bcryptjs'); -const crypto = require('crypto'); const dayjs = require('dayjs'); -const fs = require('fs'); const multer = require('multer'); -const os = require('os'); -const path = require('path'); const { v4: uuidv4 } = require('uuid'); const { requireRole } = require('../middleware/auth'); const database = require('../config/database'); @@ -25,17 +25,7 @@ const { normalizeStorageClass, testObjectStorageConnection, uploadBase64ImageToDefaultS3, - uploadFileToDefaultS3, } = require('../services/objectStorage'); -const { - compareVersions, - getLatestDesktopRelease, - listDesktopReleases, - normalizeArchitecture, - normalizePlatform, - publishDesktopRelease, - validateVersion, -} = require('../services/desktopReleaseService'); const { listOnlineDesktopUsers } = require('../services/desktopPresenceService'); const { getSystemDbConfig, @@ -129,44 +119,6 @@ const productCodeImportUpload = multer({ }, }); -const desktopReleaseTempDir = path.join(os.tmpdir(), 'maixiang-desktop-releases'); -fs.mkdirSync(desktopReleaseTempDir, { recursive: true }); -const desktopReleaseUpload = multer({ - dest: desktopReleaseTempDir, - limits: { - fileSize: 300 * 1024 * 1024, - files: 1, - }, - fileFilter: (_req, file, callback) => { - if (/\.(exe|dmg)$/i.test(String(file.originalname || ''))) { - callback(null, true); - return; - } - callback(new Error('仅支持上传 Windows(.exe)或 macOS(.dmg)安装包')); - }, -}); - -const uploadDesktopReleaseInstaller = (req, res, next) => { - desktopReleaseUpload.single('installer')(req, res, (error) => { - if (!error) return next(); - if (error.code === 'LIMIT_FILE_SIZE') { - return res.status(413).json({ success: false, error: '安装包不能超过 300 MB' }); - } - return res.status(400).json({ success: false, error: error.message || '安装包上传失败' }); - }); -}; - -async function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - await new Promise((resolve, reject) => { - const stream = fs.createReadStream(filePath); - stream.on('data', (chunk) => hash.update(chunk)); - stream.on('error', reject); - stream.on('end', resolve); - }); - return hash.digest('hex'); -} - const runBackfill = async (job) => { try { const totalStepsPerDay = 21; @@ -208,93 +160,6 @@ router.get('/desktop-presence', async (_req, res) => { } }); -router.get('/desktop-releases', async (req, res) => { - try { - const releases = await listDesktopReleases(req.query.limit); - res.json({ - success: true, - data: releases.map((release) => ({ - ...release, - downloadUrl: `/api/desktop-client/releases/${release.id}/download`, - })), - }); - } catch (error) { - console.error('获取桌面客户端发布记录失败:', error); - res.status(500).json({ success: false, error: '获取桌面客户端发布记录失败' }); - } -}); - -router.post('/desktop-releases', uploadDesktopReleaseInstaller, async (req, res) => { - const tempFilePath = req.file?.path; - try { - if (!req.file) { - return res.status(400).json({ success: false, error: '请选择需要发布的桌面客户端安装包' }); - } - const version = validateVersion(req.body?.version); - const originalName = String(req.file.originalname || ''); - const platform = normalizePlatform(req.body?.platform, originalName); - const architecture = normalizeArchitecture(req.body?.architecture, originalName, platform); - const expectedExtension = platform === 'darwin' ? '.dmg' : '.exe'; - if (!originalName.toLowerCase().endsWith(expectedExtension)) { - return res.status(400).json({ - success: false, - error: platform === 'darwin' ? 'macOS 安装包必须为 .dmg 格式' : 'Windows 安装包必须为 .exe 格式', - }); - } - const latest = await getLatestDesktopRelease(platform, architecture); - if (latest && compareVersions(version, latest.version) < 0) { - return res.status(409).json({ - success: false, - error: `发布版本 v${version} 低于当前平台最新版本 v${latest.version}`, - }); - } - - const fileName = platform === 'darwin' - ? `pdd-auto-sale-${version}-${architecture}.dmg` - : `pdd-auto-sale-${version}-setup.exe`; - const objectKey = `desktop-client/${platform}/${version}/${Date.now()}-${fileName}`; - const mimeType = platform === 'darwin' - ? 'application/x-apple-diskimage' - : 'application/vnd.microsoft.portable-executable'; - const [sha256, uploadResult] = await Promise.all([ - sha256File(tempFilePath), - uploadFileToDefaultS3(tempFilePath, { - key: objectKey, - mimeType, - }), - ]); - const release = await publishDesktopRelease({ - version, - platform, - architecture, - releaseNotes: String(req.body?.releaseNotes || '').trim(), - objectKey: uploadResult.key, - fileName, - fileSize: req.file.size, - sha256, - publishedByUserId: Number(req.user?.sub || req.user?.id || 0) || null, - publishedByName: req.user?.name || req.user?.username || req.user?.email || '', - }); - - res.status(201).json({ - success: true, - message: `${platform === 'darwin' ? 'macOS' : 'Windows'} 客户端 v${version} 已发布`, - data: { - ...release, - downloadUrl: `/api/desktop-client/releases/${release.id}/download`, - }, - }); - } catch (error) { - console.error('发布桌面客户端失败:', error); - const status = /版本号格式/.test(error.message) ? 400 : 500; - res.status(status).json({ success: false, error: error.message || '发布桌面客户端失败' }); - } finally { - if (tempFilePath) { - await fs.promises.unlink(tempFilePath).catch(() => {}); - } - } -}); - router.get('/cache-performance', async (_req, res) => { const dataPayloadStats = getStatsSnapshot(); res.json({ @@ -1622,12 +1487,10 @@ router.post('/work-order-config/test', async (req, res) => { const DEFAULT_DATABASE_CONFIG_NAME = 'cloud_database'; const CLOUD_DATABASE_PUBLIC_CONFIG_NAME = 'cloud_database_public'; const PLANNING_DATABASE_CONFIG_NAME = 'planning_database'; -const AI_BUYER_SHOW_DATABASE_CONFIG_NAME = 'ai_buyer_show_database'; const PROTECTED_DATABASE_CONFIG_NAMES = new Set([ DEFAULT_DATABASE_CONFIG_NAME, CLOUD_DATABASE_PUBLIC_CONFIG_NAME, PLANNING_DATABASE_CONFIG_NAME, - AI_BUYER_SHOW_DATABASE_CONFIG_NAME, ]); function normalizeDatabaseConfigName(rawName) { @@ -1640,6 +1503,11 @@ function normalizeDatabaseConfigName(rawName) { error.statusCode = 400; throw error; } + if (isRetiredDatabaseConfigName(configName)) { + const error = new Error('该功能已下线,不能创建对应数据库配置'); + error.statusCode = 410; + throw error; + } return configName; } diff --git a/backend/src/api/aiBuyerShow.js b/backend/src/api/aiBuyerShow.js deleted file mode 100644 index ad1709d..0000000 --- a/backend/src/api/aiBuyerShow.js +++ /dev/null @@ -1,397 +0,0 @@ -const express = require('express'); -const multer = require('multer'); -const axios = require('axios'); -const { - buildDefaultPrompt, - createAiBuyerShowJob, - generateAiBuyerShowReviews, - getAiBuyerShowHistoryDetail, - getAiBuyerShowJob, - getQueueStats, - getWorkerRuntimeStatus, - listAiBuyerShowHistory, - listAiBuyerShowJobs, - testAiBuyerShowDatabase, -} = require('../services/aiBuyerShowService'); -const { buildBuyerShowPrompt } = require('../services/aiBuyerShowReviewService'); -const { - createSeedPack, - deleteSeedPack, - getSeedPackDownload, - listSeedPacks, - MAX_SEED_PACK_FILE_BYTES, -} = require('../services/aiBuyerShowSeedPackService'); - -const router = express.Router(); - -const upload = multer({ - storage: multer.memoryStorage(), - limits: { - fileSize: 20 * 1024 * 1024, - files: 10, - }, - fileFilter: (_req, file, callback) => { - if (String(file.mimetype || '').startsWith('image/')) { - callback(null, true); - return; - } - callback(new Error('仅支持上传图片文件')); - }, -}); - -const uploadFields = upload.fields([ - { name: 'productImages', maxCount: 5 }, - { name: 'sceneReferenceImages', maxCount: 4 }, -]); - -function runUpload(req, res, next) { - uploadFields(req, res, (error) => { - if (!error) { - next(); - return; - } - if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') { - res.status(413).json({ success: false, error: '图片不能超过 20MB' }); - return; - } - res.status(400).json({ success: false, error: error.message || '图片上传失败' }); - }); -} - -const seedPackUpload = multer({ - storage: multer.memoryStorage(), - limits: { - fileSize: MAX_SEED_PACK_FILE_BYTES, - files: 1, - }, - fileFilter: (_req, file, callback) => { - const isZip = String(file.mimetype || '') === 'application/zip' - || String(file.mimetype || '') === 'application/x-zip-compressed' - || /\.zip$/i.test(String(file.originalname || '')); - if (isZip) { - callback(null, true); - return; - } - callback(new Error('种菜资源包仅支持上传 ZIP 文件')); - }, -}).single('file'); - -function runSeedPackUpload(req, res, next) { - seedPackUpload(req, res, (error) => { - if (!error) { - next(); - return; - } - if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') { - res.status(413).json({ success: false, error: '种菜资源包 ZIP 不能超过 200MB' }); - return; - } - res.status(400).json({ success: false, error: error.message || '种菜资源包上传失败' }); - }); -} - -function normalizeList(value) { - const raw = Array.isArray(value) ? value : String(value || '').split(/[\n,,]+/); - return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean))); -} - -function isAllowedImageUrl(value) { - try { - const parsed = new URL(String(value || '')); - return ['http:', 'https:'].includes(parsed.protocol) && ( - parsed.hostname.endsWith('aliyuncs.com') - || parsed.hostname.endsWith('volces.com') - || parsed.hostname.endsWith('jiansuntuzhi.cn') - || parsed.hostname.endsWith('yydd.ai') - || parsed.hostname.endsWith('jiansun.vip') - ); - } catch (_) { - return false; - } -} - -router.get('/runtime', async (_req, res) => { - try { - const databaseStatus = await testAiBuyerShowDatabase(); - const queue = databaseStatus.ok ? await getQueueStats() : null; - const worker = databaseStatus.ok ? await getWorkerRuntimeStatus(queue || {}) : { - enabled: false, - hasApiKey: false, - status: 'database_unavailable', - source: 'job-table-observation', - lastActivityAt: null, - lastError: '', - }; - res.json({ - success: true, - data: { - database: databaseStatus, - queue, - worker, - }, - }); - } catch (error) { - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || 'AI买家秀运行状态查询失败', - data: { - database: { - ok: false, - missingTables: [], - }, - }, - }); - } -}); - -router.post('/prompt-preview', express.json({ limit: '1mb' }), async (req, res) => { - try { - const body = req.body || {}; - res.json({ - success: true, - data: { - prompt: body.review - ? buildBuyerShowPrompt({ - review: body.review, - analysis: body.analysis || {}, - style: body.buyerShowStyle, - }) - : buildDefaultPrompt(body), - }, - }); - } catch (error) { - res.status(500).json({ success: false, error: error.message || '提示词预览失败' }); - } -}); - -router.post('/reviews', runUpload, async (req, res) => { - try { - const body = req.body || {}; - const result = await generateAiBuyerShowReviews({ - productName: body.productName || body.spu, - spu: body.spu || body.productName, - scene: body.scene, - userType: body.userType, - sellingPoints: body.sellingPoints, - useEmoji: body.useEmoji, - reviewTotal: body.reviewTotal, - productImageFiles: req.files?.productImages || [], - }, req.user || {}); - res.json({ success: true, data: result }); - } catch (error) { - console.error('[ai-buyer-show] generate reviews failed:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || '攻心评价生成失败', - }); - } -}); - -router.post('/jobs', runUpload, async (req, res) => { - try { - const body = req.body || {}; - const result = await createAiBuyerShowJob({ - productName: body.productName || body.spu, - spu: body.spu || body.productName, - scene: body.scene, - userType: body.userType, - sellingPoints: body.sellingPoints, - reviewText: body.reviewText, - generationId: body.generationId, - reviewRecordId: body.reviewRecordId, - prompt: body.prompt, - negativePrompt: body.negativePrompt, - buyerShowStyle: body.buyerShowStyle, - imageCount: 5, - aspectRatio: body.aspectRatio, - imageSize: body.imageSize, - productImageUrls: normalizeList(body.productImageUrls), - sceneReferenceImageUrls: normalizeList(body.sceneReferenceImageUrls), - productImageFiles: req.files?.productImages || [], - sceneReferenceImageFiles: req.files?.sceneReferenceImages || [], - }, req.user || {}); - - res.json({ success: true, data: result }); - } catch (error) { - console.error('[ai-buyer-show] create job failed:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || 'AI买家秀任务创建失败', - }); - } -}); - -router.get('/jobs', async (req, res) => { - try { - const result = await listAiBuyerShowJobs({ - page: req.query.page, - pageSize: req.query.pageSize, - spu: req.query.spu, - includeTotal: req.query.includeTotal !== '0', - }); - res.json({ success: true, data: result }); - } catch (error) { - console.error('[ai-buyer-show] list jobs failed:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || 'AI买家秀任务列表查询失败', - }); - } -}); - -router.get('/history', async (req, res) => { - try { - const data = await listAiBuyerShowHistory({ - page: req.query.page, - pageSize: req.query.pageSize, - keyword: req.query.keyword, - }); - res.set('Cache-Control', 'private, no-store'); - res.json({ success: true, data }); - } catch (error) { - console.error('[ai-buyer-show] list history failed:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || '历史生成记录加载失败', - }); - } -}); - -router.get('/history/:id', async (req, res) => { - try { - const data = await getAiBuyerShowHistoryDetail(req.params.id); - if (!data) { - res.status(404).json({ success: false, error: '历史生成记录不存在' }); - return; - } - res.set('Cache-Control', 'private, no-store'); - res.json({ success: true, data }); - } catch (error) { - console.error('[ai-buyer-show] get history failed:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || '历史生成详情加载失败', - }); - } -}); - -router.get('/image-proxy', async (req, res) => { - const imageUrl = String(req.query.url || '').trim(); - if (!isAllowedImageUrl(imageUrl)) { - res.status(400).json({ success: false, error: '不支持的图片地址' }); - return; - } - - try { - const response = await axios.get(imageUrl, { - responseType: 'stream', - timeout: 120000, - maxContentLength: 30 * 1024 * 1024, - headers: { 'User-Agent': 'Mozilla/5.0' }, - }); - res.set('Content-Type', response.headers['content-type'] || 'application/octet-stream'); - res.set('Cache-Control', 'private, max-age=300'); - response.data.pipe(res); - } catch (error) { - res.status(error.response?.status || 502).json({ - success: false, - error: error.message || '图片下载失败', - }); - } -}); - -router.get('/jobs/:jobId', async (req, res) => { - try { - const job = await getAiBuyerShowJob(req.params.jobId); - if (!job) { - res.status(404).json({ success: false, error: '任务不存在' }); - return; - } - res.json({ success: true, data: job }); - } catch (error) { - console.error('[ai-buyer-show] get job failed:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || 'AI买家秀任务查询失败', - }); - } -}); - -router.get('/queue-stats', async (req, res) => { - try { - const data = await getQueueStats(req.query.jobId || ''); - res.json({ success: true, data }); - } catch (error) { - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || 'AI买家秀队列统计查询失败', - }); - } -}); - -router.post('/seed-packs', runSeedPackUpload, async (req, res) => { - try { - const body = req.body || {}; - const data = await createSeedPack({ - shopName: body.shopName, - productId: body.productId, - skuName: body.skuName, - fileName: body.fileName || req.file?.originalname, - reviewCount: body.reviewCount, - file: req.file, - }, req.user || {}); - res.json({ success: true, data }); - } catch (error) { - console.error('[ai-buyer-show] create seed pack failed:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || '种菜资源包上传失败', - }); - } -}); - -router.get('/seed-packs', async (req, res) => { - try { - const data = await listSeedPacks({ - page: req.query.page, - pageSize: req.query.pageSize, - keyword: req.query.keyword, - }); - res.set('Cache-Control', 'private, no-store'); - res.json({ success: true, data }); - } catch (error) { - console.error('[ai-buyer-show] list seed packs failed:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || '种菜资源包列表查询失败', - }); - } -}); - -router.get('/seed-packs/:id/download', async (req, res) => { - try { - const data = await getSeedPackDownload(req.params.id); - res.json({ success: true, data }); - } catch (error) { - console.error('[ai-buyer-show] get seed pack download failed:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || '种菜资源包下载信息获取失败', - }); - } -}); - -router.delete('/seed-packs/:id', async (req, res) => { - try { - const data = await deleteSeedPack(req.params.id); - res.json({ success: true, data }); - } catch (error) { - console.error('[ai-buyer-show] delete seed pack failed:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || '种菜资源包删除失败', - }); - } -}); - -module.exports = router; diff --git a/backend/src/api/csQuality.js b/backend/src/api/csQuality.js deleted file mode 100644 index e74572d..0000000 --- a/backend/src/api/csQuality.js +++ /dev/null @@ -1,93 +0,0 @@ -'use strict'; - -const express = require('express'); -const { - getGoods, - getChatDataMeta, - getCustomers, - getMessages, - getProductProfile, - startAnalysisJob, - getAnalysisJob, - runAnalysis, - listHistory, - getHistoryDetail, -} = require('../services/csQualityService'); - -const router = express.Router(); - -function sendError(res, error, fallback = '客服质检请求失败') { - const status = Number(error?.statusCode) || (error?.message?.includes('不能超过30天') ? 400 : 500); - console.error('[CsQuality]', { - message: error?.message || fallback, - code: error?.code || null, - status, - stack: error?.stack || null, - }); - res.status(status).json({ success: false, error: error.message || fallback }); -} - -router.get('/goods', async (req, res) => { - try { - res.json({ success: true, data: await getGoods(req.query) }); - } catch (error) { sendError(res, error, '加载商品聊天列表失败'); } -}); - -router.get('/data-meta', async (_req, res) => { - try { - res.json({ success: true, data: await getChatDataMeta() }); - } catch (error) { sendError(res, error, '加载聊天数据日期范围失败'); } -}); - -router.get('/goods/:goodsId/profile', async (req, res) => { - try { - res.json({ success: true, data: await getProductProfile({ ...req.query, goodsId: req.params.goodsId }) }); - } catch (error) { sendError(res, error, '加载商品表现失败'); } -}); - -router.get('/customers', async (req, res) => { - try { - res.json({ success: true, data: await getCustomers(req.query) }); - } catch (error) { sendError(res, error, '加载客户列表失败'); } -}); - -router.get('/messages', async (req, res) => { - try { - res.json({ success: true, data: await getMessages(req.query) }); - } catch (error) { sendError(res, error, '加载聊天内容失败'); } -}); - -router.post('/analysis', async (req, res) => { - try { - res.json({ success: true, data: await runAnalysis(req.body || {}, req.user || {}) }); - } catch (error) { sendError(res, error, 'AI质检失败'); } -}); - -router.post('/analysis-jobs', async (req, res) => { - try { - const data = await startAnalysisJob(req.body || {}, req.user || {}); - res.status(202).json({ success: true, data }); - } catch (error) { sendError(res, error, '创建AI质检任务失败'); } -}); - -router.get('/analysis-jobs/:analysisId', async (req, res) => { - try { - res.json({ success: true, data: await getAnalysisJob(req.params.analysisId) }); - } catch (error) { sendError(res, error, '查询AI质检进度失败'); } -}); - -router.get('/analysis-history', async (req, res) => { - try { - res.json({ success: true, data: await listHistory(req.query) }); - } catch (error) { sendError(res, error, '加载AI质检历史失败'); } -}); - -router.get('/analysis-history/:id', async (req, res) => { - try { - const data = await getHistoryDetail(req.params.id); - if (!data) return res.status(404).json({ success: false, error: '历史质检记录不存在' }); - return res.json({ success: true, data }); - } catch (error) { return sendError(res, error, '加载AI质检历史详情失败'); } -}); - -module.exports = router; diff --git a/backend/src/api/desktopClient.js b/backend/src/api/desktopClient.js index 6d8df93..5c13ac4 100644 --- a/backend/src/api/desktopClient.js +++ b/backend/src/api/desktopClient.js @@ -1,18 +1,6 @@ const express = require('express'); const crypto = require('crypto'); const authMiddleware = require('../middleware/auth'); -const { - buildPublicUrl, - getDefaultS3Config, - getObjectFromDefaultS3, -} = require('../services/objectStorage'); -const { - compareVersions, - getDesktopReleaseById, - getLatestDesktopRelease, - mapRelease, - normalizeVersion, -} = require('../services/desktopReleaseService'); const { DesktopSessionError, createDesktopSession, @@ -24,23 +12,6 @@ const { const router = express.Router(); -function publicRelease(release) { - const mapped = mapRelease(release); - if (!mapped) return null; - return { - id: mapped.id, - version: mapped.version, - releaseNotes: mapped.releaseNotes, - platform: mapped.platform, - architecture: mapped.architecture, - fileName: mapped.fileName, - fileSize: mapped.fileSize, - sha256: mapped.sha256, - publishedAt: mapped.publishedAt, - downloadUrl: `/api/desktop-client/releases/${mapped.id}/download`, - }; -} - function requestUserId(req) { return req.user?.sub ?? req.user?.id; } @@ -164,68 +135,4 @@ router.post('/session/revoke', authMiddleware, async (req, res) => { } }); -router.get('/latest', async (req, res) => { - try { - const platform = req.query.platform || 'win32'; - const architecture = req.query.architecture || ''; - const release = await getLatestDesktopRelease(platform, architecture); - const currentVersion = normalizeVersion(req.query.currentVersion); - const data = publicRelease(release); - res.setHeader('Cache-Control', 'no-store, max-age=0'); - res.json({ - success: true, - data: data ? { - ...data, - currentVersion: currentVersion || null, - updateAvailable: currentVersion ? compareVersions(data.version, currentVersion) > 0 : null, - } : null, - }); - } catch (error) { - console.error('获取桌面客户端最新版本失败:', error); - res.status(500).json({ success: false, error: '获取桌面客户端最新版本失败' }); - } -}); - -router.get('/releases/:id/download', async (req, res) => { - try { - const release = await getDesktopReleaseById(Number(req.params.id)); - if (!release) { - return res.status(404).json({ success: false, error: '安装包不存在或已下线' }); - } - - const storageConfig = await getDefaultS3Config(); - if (storageConfig?.customDomain) { - return res.redirect(302, buildPublicUrl(storageConfig, release.objectKey)); - } - - const object = await getObjectFromDefaultS3(release.objectKey, { - range: req.headers.range, - }); - const encodedName = encodeURIComponent(release.fileName).replace(/%20/g, '+'); - res.status(object.ContentRange ? 206 : 200); - const fallbackContentType = release.platform === 'darwin' - ? 'application/x-apple-diskimage' - : 'application/vnd.microsoft.portable-executable'; - res.setHeader('Content-Type', object.ContentType || fallbackContentType); - res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedName}`); - res.setHeader('Accept-Ranges', object.AcceptRanges || 'bytes'); - res.setHeader('Cache-Control', 'private, max-age=3600'); - if (object.ContentLength != null) res.setHeader('Content-Length', String(object.ContentLength)); - if (object.ContentRange) res.setHeader('Content-Range', object.ContentRange); - if (object.ETag) res.setHeader('ETag', object.ETag); - - object.Body.on('error', (error) => { - console.error('桌面客户端安装包下载流中断:', error); - if (!res.headersSent) res.status(502).end(); - else res.destroy(error); - }); - object.Body.pipe(res); - } catch (error) { - console.error('下载桌面客户端安装包失败:', error); - if (!res.headersSent) { - res.status(502).json({ success: false, error: '安装包下载失败,请稍后重试' }); - } - } -}); - module.exports = router; diff --git a/backend/src/api/pricingCalculator.js b/backend/src/api/pricingCalculator.js deleted file mode 100644 index a8ae5cb..0000000 --- a/backend/src/api/pricingCalculator.js +++ /dev/null @@ -1,833 +0,0 @@ -const express = require('express'); -const XLSX = require('xlsx'); -const database = require('../config/database'); -const { - getCompanySkusBySpu, - fetchFreightProfile, - getSelectionFissionProfitPricingContext, - getSelectionFissionProductCodeMeta, - saveSelectionFissionProfitPricingTable, -} = require('../services/planningSelectionFissionService'); - -const router = express.Router(); - -const HISTORY_TABLE = 'pricing_calculator_history'; -// 运费/售后画像查询设定超时,避免整页等待。 -const FREIGHT_PROFILE_TIMEOUT_MS = Math.max( - Number.parseInt(process.env.PRICING_CALC_FREIGHT_TIMEOUT_MS || '30000', 10) || 30000, - 5000 -); -const MAX_HISTORY_ROWS = 500; -const MAX_PAYLOAD_BYTES = 1024 * 1024; -const CALCULATOR_TYPES = new Set(['company', 'alibaba1688']); - -let historyTableReady = null; - -async function ensureHistoryTable() { - if (!historyTableReady) { - historyTableReady = (async () => { - // 先查表是否存在:生产/隧道账号通常只有 SELECT/INSERT/UPDATE/DELETE, - // MariaDB 对 CREATE TABLE IF NOT EXISTS 也会校验 CREATE 权限,存在时直接跳过。 - const existing = await database.local.query( - `SELECT 1 - FROM information_schema.tables - WHERE table_schema = DATABASE() - AND table_name = ? - LIMIT 1`, - [HISTORY_TABLE] - ); - if (existing.length > 0) { - return; - } - await database.local.query( - `CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', - user_id BIGINT NOT NULL COMMENT '测算人用户ID', - user_name VARCHAR(100) NULL COMMENT '测算人显示名', - spu_code VARCHAR(160) NOT NULL COMMENT '公司SPU', - brand VARCHAR(100) NULL COMMENT '品牌名称', - note VARCHAR(500) NULL COMMENT '备注', - row_count INT NOT NULL DEFAULT 0 COMMENT '测算行数', - payload_json LONGTEXT NOT NULL COMMENT '完整测算快照 JSON', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX idx_pricing_calc_history_user_created (user_id, created_at) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算历史记录'` - ); - })(); - } - try { - await historyTableReady; - } catch (error) { - // 建表失败后允许下次请求重试 - historyTableReady = null; - throw error; - } -} - -function sendError(res, error, fallbackMessage) { - const status = error.status || error.statusCode || 500; - res.status(status).json({ - success: false, - error: error.message || fallbackMessage, - }); -} - -function getRequestUser(req) { - const user = req.user || {}; - return { - id: Number(user.sub || user.id || 0) || null, - name: user.name || user.username || '', - isAdmin: user.role === 'admin', - }; -} - -function requireUserId(req, res) { - const { id } = getRequestUser(req); - if (!id) { - res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); - return null; - } - return id; -} - -const normalizeText = (value, maxLength) => String(value ?? '').trim().slice(0, maxLength); - -const normalizeCalculatorType = (value) => ( - CALCULATOR_TYPES.has(value) ? value : 'company' -); - -function sanitizeHistoryProduct(input) { - const product = input && typeof input === 'object' ? input : {}; - const dimensions = (Array.isArray(product.skuDimensions) ? product.skuDimensions : []) - .slice(0, 4) - .map((dimension) => ({ - name: normalizeText(dimension?.name, 120), - values: (Array.isArray(dimension?.values) ? dimension.values : []) - .slice(0, 500) - .map((value) => normalizeText(value, 500)) - .filter(Boolean), - })) - .filter((dimension) => dimension.name); - return { - productId: normalizeText(product.productId, 80), - title: normalizeText(product.title, 500), - mainImage: normalizeText(product.mainImage, 2000), - sourceUrl: normalizeText(product.sourceUrl, 2000), - storeName: normalizeText(product.storeName, 300), - category: normalizeText(product.category, 500), - skuDimensions: dimensions, - }; -} - -function mapSkuRow(row) { - const qgyf = Number(row?.raw?.qgyf); - return { - skuCode: row.skuCode || '', - status: row.saleStatus || '', - stock: row.stock ?? null, - stockWarehouses: Array.isArray(row.stockWarehouses) ? row.stockWarehouses : [], - name: row.skuName || row.productName || '', - image: row.skuImageUrl || '', - size: row.size || '', - color: row.colorName || '', - cost: row.cost ?? null, - minimumPrice: row.minimumPrice ?? null, - salePrice: row?.raw?.sale_price ?? row?.raw?.salePrice ?? null, - // qgyf = 聚水潭接口返回的全国平均运费(按条码) - freight: Number.isFinite(qgyf) ? qgyf : null, - supplier: row.supplierName || '', - category: row.categoryName || '', - lengthCm: row.lengthCm ?? row?.raw?.length_cm ?? row?.raw?.length ?? null, - widthCm: row.widthCm ?? row?.raw?.width_cm ?? row?.raw?.width ?? null, - heightCm: row.heightCm ?? row?.raw?.height_cm ?? row?.raw?.height ?? null, - weight: row.weight ?? row?.raw?.weight ?? row?.raw?.gross_weight ?? null, - }; -} - -async function fetchFreightProfileBestEffort(spuCode, skuRows, user) { - const emptyProfile = { - nationalFreight: null, - zone1Freight: null, - zone2Freight: null, - zone3Freight: null, - zone4Freight: null, - aftersaleRatio: null, - minimumPricesBySku: {}, - raw: null, - }; - try { - const result = await Promise.race([ - fetchFreightProfile({ spuCode, skuRows }, user), - new Promise((resolve) => { - setTimeout(() => resolve({ ...emptyProfile, warning: '运费/售后信息查询超时,可手动填写运费' }), FREIGHT_PROFILE_TIMEOUT_MS); - }), - ]); - return result && typeof result === 'object' ? { ...emptyProfile, ...result } : emptyProfile; - } catch (error) { - return { ...emptyProfile, warning: error.message || '运费/售后信息查询失败' }; - } -} - -// 拉取 SPU 下全部条码(含销售、退市、停产)+ 运费/售后画像(尽力而为) -// 运费由聚水潭接口 qgyf 字段按条码直出, -// 默认跳过;如需售后占比/最低限价画像,可设 PRICING_CALC_FREIGHT_PROFILE=on 重新开启。 -const FREIGHT_PROFILE_ENABLED = String(process.env.PRICING_CALC_FREIGHT_PROFILE || 'off').toLowerCase() === 'on'; - -function getSelectionSkuCodeSet(selectionContext) { - if (!selectionContext) return null; - const selectedSkuCodes = Array.isArray(selectionContext.skuSceneImages?.selectedSkuCodes) - ? selectionContext.skuSceneImages.selectedSkuCodes - : []; - const fallbackSkuCodes = Array.isArray(selectionContext.rows) - ? selectionContext.rows.map((row) => row.skuCode) - : []; - return new Set( - (selectedSkuCodes.length > 0 ? selectedSkuCodes : fallbackSkuCodes) - .map((skuCode) => normalizeText(skuCode, 160).toUpperCase()) - .filter(Boolean) - ); -} - -function filterSkuRowsForSelectionContext(skuRows, selectionContext) { - const rows = Array.isArray(skuRows) ? skuRows : []; - const selectedSkuCodes = getSelectionSkuCodeSet(selectionContext); - if (!selectedSkuCodes) return rows; - return rows.filter((row) => selectedSkuCodes.has( - normalizeText(row?.skuCode || row?.sku_code || row?.barcode, 160).toUpperCase() - )); -} - -router.get('/product-code/meta', async (req, res) => { - const userId = requireUserId(req, res); - if (!userId) return; - try { - res.json({ success: true, data: await getSelectionFissionProductCodeMeta() }); - } catch (error) { - sendError(res, error, '公司SKU编码配置加载失败'); - } -}); - -router.get('/spu/:spuCode', async (req, res) => { - const spuCode = normalizeText(req.params.spuCode, 160).toUpperCase(); - if (!spuCode) { - return res.status(400).json({ success: false, error: '请输入公司 SPU' }); - } - - try { - const planId = Number.parseInt(req.query.planId, 10) || 0; - const itemId = Number.parseInt(req.query.itemId, 10) || 0; - if (Boolean(planId) !== Boolean(itemId)) { - return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' }); - } - const skuResult = await getCompanySkusBySpu(spuCode, { includeInactive: true }); - const allSkuRows = Array.from(skuResult.rows || []); - if (allSkuRows.length === 0) { - return res.status(404).json({ - success: false, - error: `未查询到 SPU「${spuCode}」的条码,请确认 SPU 是否正确`, - }); - } - - let selectionContext = null; - if (planId && itemId) { - selectionContext = await getSelectionFissionProfitPricingContext(planId, itemId); - if (selectionContext.spuCode.toUpperCase() !== spuCode) { - return res.status(409).json({ success: false, error: `该选品任务绑定的是SPU ${selectionContext.spuCode}` }); - } - } - const skuRows = filterSkuRowsForSelectionContext(allSkuRows, selectionContext); - if (selectionContext && skuRows.length === 0) { - return res.status(409).json({ - success: false, - error: '选品任务已选 SKU 不在当前 SPU 条码列表中,请返回 SKU 节点重新确认', - }); - } - - const freightProfile = FREIGHT_PROFILE_ENABLED - ? await fetchFreightProfileBestEffort(spuCode, skuRows, req.user) - : { nationalFreight: null, aftersaleRatio: null, minimumPricesBySku: {}, warning: null }; - const warnings = [...(skuResult.warnings || [])]; - if (freightProfile.warning) { - warnings.push(freightProfile.warning); - } - - const savedRowsBySku = new Map((selectionContext?.rows || []).map((row) => [ - String(row.skuCode || '').toUpperCase(), - row, - ])); - res.json({ - success: true, - data: { - spu: spuCode, - source: skuResult.source, - warnings, - freightProfile: { - nationalFreight: freightProfile.nationalFreight ?? null, - zone1Freight: freightProfile.zone1Freight ?? null, - zone2Freight: freightProfile.zone2Freight ?? null, - zone3Freight: freightProfile.zone3Freight ?? null, - zone4Freight: freightProfile.zone4Freight ?? null, - aftersaleRatio: freightProfile.aftersaleRatio ?? null, - minimumPricesBySku: freightProfile.minimumPricesBySku || {}, - }, - selectionContext: selectionContext ? { - planId: selectionContext.planId, - itemId: selectionContext.itemId, - taskCode: selectionContext.taskCode, - schemeCode: selectionContext.schemeCode, - listedItemId: selectionContext.listedItemId, - pricingTableSavedAt: selectionContext.pricingTableSavedAt, - pricingTableSavedBy: selectionContext.pricingTableSavedBy, - pricingTableVersion: selectionContext.pricingTableVersion, - workflowProfile: selectionContext.workflowProfile, - productCodeStatus: selectionContext.productCodeStatus, - productCodeStyleCode: selectionContext.productCodeStyleCode, - productCodeConfig: selectionContext.productCodeConfig, - productCodeErrorMessage: selectionContext.productCodeErrorMessage, - productCodeRequestedAt: selectionContext.productCodeRequestedAt, - productCodeCompletedAt: selectionContext.productCodeCompletedAt, - } : null, - rows: skuRows.map((skuRow) => { - const mapped = mapSkuRow(skuRow); - const saved = savedRowsBySku.get(String(mapped.skuCode || '').toUpperCase()); - return { - ...mapped, - savedPricing: saved ? { - price: saved.finalPrice, - groupPrice: saved.groupPrice, - installFee: saved.installationFee, - coupon: saved.discountAmount, - activityDiscount: saved.activityDiscount, - shipping: saved.freightNational, - commission: saved.platformCommissionRatio, - promoRate: saved.promotionRatio, - adFee: saved.marketingAdRatio, - seeding: saved.farmingRatio, - afterSale: saved.aftersaleRatio, - insurance: saved.insuranceRatio, - brandFee: saved.brandUsageRatio, - targetMargin: saved.operatingProfitRatio, - originalSkuName: saved.originalSkuName, - selectedSkuName: saved.selectedSkuName, - internalProductCode: saved.internalProductCode, - productCodeProductName: saved.productCodeProductName, - productCodeColorSpec: saved.productCodeColorSpec, - } : null, - }; - }), - }, - }); - } catch (error) { - sendError(res, error, 'SPU 条码查询失败,请稍后重试'); - } -}); - -// 保存一条测算历史(完整行快照,含输入与公式结果) -router.post('/history', async (req, res) => { - const userId = requireUserId(req, res); - if (!userId) return; - - const body = req.body || {}; - const spuCode = normalizeText(body.spuCode, 160).toUpperCase(); - const calculatorType = normalizeCalculatorType(body.calculatorType); - const rows = Array.isArray(body.rows) ? body.rows : []; - if (!spuCode) { - return res.status(400).json({ success: false, error: '缺少 SPU 编码' }); - } - if (rows.length === 0) { - return res.status(400).json({ success: false, error: '测算行为空,无法保存' }); - } - if (rows.length > MAX_HISTORY_ROWS) { - return res.status(400).json({ success: false, error: `单条历史最多保存 ${MAX_HISTORY_ROWS} 行` }); - } - - const payload = { - version: 4, - calculatorType, - spuCode, - isBlackLabelShop: body.isBlackLabelShop === true, - brand: normalizeText(body.brand, 100), - note: normalizeText(body.note, 500), - savedAt: new Date().toISOString(), - summary: body.summary && typeof body.summary === 'object' ? body.summary : null, - freightProfile: body.freightProfile && typeof body.freightProfile === 'object' ? body.freightProfile : null, - product: calculatorType === 'alibaba1688' ? sanitizeHistoryProduct(body.product) : null, - rows, - }; - const payloadJson = JSON.stringify(payload); - if (Buffer.byteLength(payloadJson, 'utf8') > MAX_PAYLOAD_BYTES) { - return res.status(400).json({ success: false, error: '测算快照过大,无法保存' }); - } - - try { - let selectionPricing = null; - const planId = Number.parseInt(body.selectionContext?.planId || body.planId, 10) || 0; - const itemId = Number.parseInt(body.selectionContext?.itemId || body.itemId, 10) || 0; - if (Boolean(planId) !== Boolean(itemId)) { - return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' }); - } - if (planId && itemId) { - // 测算页会展示实时接口中的全部条码;自动上架保存用户保留的任务 SKU 子集。 - const pricingContext = await getSelectionFissionProfitPricingContext(planId, itemId); - const listingSkuCodes = new Set((pricingContext.rows || []).map((row) => ( - normalizeText(row.skuCode, 160).toUpperCase() - ))); - const listingRows = rows.filter((row) => listingSkuCodes.has( - normalizeText(row.skuCode || row.sku || row.barcode, 160).toUpperCase() - )); - selectionPricing = await saveSelectionFissionProfitPricingTable( - planId, - itemId, - { - spuCode, - rows: listingRows, - note: payload.note, - productCodeConfig: body.productCodeConfig, - }, - req.user, - { pricingContext } - ); - } - let insertId = null; - if (!selectionPricing) { - await ensureHistoryTable(); - const inserted = await database.local.insert(HISTORY_TABLE, { - user_id: userId, - user_name: getRequestUser(req).name || null, - spu_code: spuCode, - brand: payload.brand || null, - note: payload.note || null, - row_count: rows.length, - payload_json: payloadJson, - }); - insertId = inserted.insertId; - } - res.json({ - success: true, - data: { - id: insertId, - spuCode, - rowCount: rows.length, - selectionPricing: selectionPricing ? { - planId: selectionPricing.planId, - itemId: selectionPricing.itemId, - taskCode: selectionPricing.taskCode, - pricingTableSavedAt: selectionPricing.pricingTableSavedAt, - pricingTableVersion: selectionPricing.pricingTableVersion, - productCodeStatus: selectionPricing.productCodeStatus, - productCodeStyleCode: selectionPricing.productCodeStyleCode, - productCodeConfig: selectionPricing.productCodeConfig, - productCodeCompletedAt: selectionPricing.productCodeCompletedAt, - productCodes: (selectionPricing.rows || []).map((row) => ({ - skuCode: row.skuCode, - originalSkuName: row.originalSkuName, - selectedSkuName: row.selectedSkuName, - internalProductCode: row.internalProductCode, - productCodeProductName: row.productCodeProductName, - })), - } : null, - }, - }); - } catch (error) { - sendError(res, error, '保存测算记录失败'); - } -}); - -// 历史列表(管理员可见全部,普通用户仅本人,不含快照正文) -router.get('/history', async (req, res) => { - const userId = requireUserId(req, res); - if (!userId) return; - const { isAdmin } = getRequestUser(req); - - const page = Math.max(Number.parseInt(req.query.page, 10) || 1, 1); - const pageSize = Math.min(Math.max(Number.parseInt(req.query.pageSize, 10) || 20, 1), 100); - const spuCode = normalizeText(req.query.spuCode, 160).toUpperCase(); - const calculatorType = normalizeText(req.query.calculatorType, 30); - const offset = (page - 1) * pageSize; - - try { - await ensureHistoryTable(); - const where = []; - const params = []; - if (!isAdmin) { - where.push('user_id = ?'); - params.push(userId); - } - if (spuCode) { - where.push('spu_code = ?'); - params.push(spuCode); - } - if (calculatorType === 'alibaba1688') { - where.push('payload_json LIKE ?'); - params.push('%"calculatorType":"alibaba1688"%'); - } else if (calculatorType === 'company') { - // 兼容 version 1-3 的公司品历史,它们还没有 calculatorType 字段。 - where.push('payload_json NOT LIKE ?'); - params.push('%"calculatorType":"alibaba1688"%'); - } - const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; - - const [countRows, list] = await Promise.all([ - database.local.query(`SELECT COUNT(*) AS total FROM ${HISTORY_TABLE} ${whereSql}`, params), - database.local.query( - `SELECT id, user_id, user_name, spu_code, brand, note, row_count, created_at, updated_at - FROM ${HISTORY_TABLE} - ${whereSql} - ORDER BY id DESC - LIMIT ${pageSize} OFFSET ${offset}`, - params - ), - ]); - - res.json({ - success: true, - data: { - list: list.map((row) => ({ - id: row.id, - userName: row.user_name || '', - spuCode: row.spu_code, - brand: row.brand || '', - note: row.note || '', - rowCount: row.row_count, - calculatorType: calculatorType === 'alibaba1688' ? 'alibaba1688' : 'company', - createdAt: row.created_at, - updatedAt: row.updated_at, - })), - total: Number(countRows[0]?.total || 0), - page, - pageSize, - }, - }); - } catch (error) { - sendError(res, error, '查询测算历史失败'); - } -}); - -async function loadOwnHistoryRow(req, res) { - const userId = requireUserId(req, res); - if (!userId) return null; - const { isAdmin } = getRequestUser(req); - - const id = Number.parseInt(req.params.id, 10); - if (!Number.isFinite(id) || id <= 0) { - res.status(400).json({ success: false, error: '无效的历史记录 ID' }); - return null; - } - - await ensureHistoryTable(); - // 服务端强制数据边界:管理员可见全部,普通用户的他人记录表现为不存在 - const rows = isAdmin - ? await database.local.query(`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? LIMIT 1`, [id]) - : await database.local.query( - `SELECT * FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ? LIMIT 1`, - [id, userId] - ); - if (rows.length === 0) { - res.status(404).json({ success: false, error: '测算记录不存在或已被删除' }); - return null; - } - return rows[0]; -} - -// 历史详情(仅本人) -router.get('/history/:id', async (req, res) => { - try { - const row = await loadOwnHistoryRow(req, res); - if (!row) return; - - let payload = null; - try { - payload = JSON.parse(row.payload_json); - } catch { - payload = null; - } - - res.json({ - success: true, - data: { - id: row.id, - userName: row.user_name || '', - spuCode: row.spu_code, - brand: row.brand || '', - note: row.note || '', - rowCount: row.row_count, - createdAt: row.created_at, - updatedAt: row.updated_at, - payload, - }, - }); - } catch (error) { - sendError(res, error, '查询测算记录失败'); - } -}); - -// 删除历史(本人,或管理员可管理全部) -router.delete('/history/:id', async (req, res) => { - try { - const row = await loadOwnHistoryRow(req, res); - if (!row) return; - - await database.local.query( - `DELETE FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ?`, - [row.id, row.user_id] - ); - res.json({ success: true, data: { id: row.id } }); - } catch (error) { - sendError(res, error, '删除测算记录失败'); - } -}); - -// 导出测算表:按前端给定的列顺序(与页面显示一致)与行数据生成 xlsx 下载 -const MAX_EXPORT_ROWS = 2000; -const MAX_EXPORT_COLUMNS = 40; - -router.post('/export', async (req, res) => { - const userId = requireUserId(req, res); - if (!userId) return; - - const body = req.body || {}; - const spuCode = normalizeText(body.spu, 160).toUpperCase() || 'SPU'; - const columns = Array.isArray(body.columns) ? body.columns : []; - const rows = Array.isArray(body.rows) ? body.rows : []; - - const validColumns = columns - .filter((col) => col && typeof col === 'object') - .slice(0, MAX_EXPORT_COLUMNS) - .map((col) => ({ - key: normalizeText(col.key, 60), - label: normalizeText(col.label, 60) || normalizeText(col.key, 60), - percent: col.percent === true, - })) - .filter((col) => col.key); - - if (validColumns.length === 0) { - return res.status(400).json({ success: false, error: '缺少导出列配置' }); - } - if (rows.length === 0) { - return res.status(400).json({ success: false, error: '导出数据为空' }); - } - if (rows.length > MAX_EXPORT_ROWS) { - return res.status(400).json({ success: false, error: `单次最多导出 ${MAX_EXPORT_ROWS} 行` }); - } - - try { - const aoa = [validColumns.map((col) => col.label)]; - rows.forEach((row) => { - aoa.push(validColumns.map((col) => { - const value = row && typeof row === 'object' ? row[col.key] : null; - return value === null || value === undefined ? '' : value; - })); - }); - const sheet = XLSX.utils.aoa_to_sheet(aoa); - // 百分比列:保持数值,设置百分比数字格式(0.06 在 Excel 显示为 6.00%) - validColumns.forEach((col, colIndex) => { - if (!col.percent) return; - for (let rowIndex = 1; rowIndex < aoa.length; rowIndex += 1) { - const addr = XLSX.utils.encode_cell({ c: colIndex, r: rowIndex }); - const cell = sheet[addr]; - if (cell && typeof cell.v === 'number') { - cell.z = '0.00%'; - } - } - }); - sheet['!cols'] = validColumns.map((col) => ({ wch: Math.max(12, String(col.label).length * 2 + 6) })); - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(workbook, sheet, '上架测算'); - const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }); - - const stamp = new Date(); - const pad = (n) => String(n).padStart(2, '0'); - const filename = `上架测算_${spuCode}_${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.xlsx`; - res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); - res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`); - res.send(buffer); - } catch (error) { - res.status(500).json({ success: false, error: `导出失败:${error.message}` }); - } -}); - -// ===== 用户个性化默认值(每运营维护自己的配置,无配置时前端用系统默认) ===== -const PREFS_TABLE = 'pricing_calculator_user_prefs'; -let prefsTableReady = null; - -async function ensurePrefsTable() { - if (!prefsTableReady) { - prefsTableReady = (async () => { - const existing = await database.local.query( - `SELECT 1 - FROM information_schema.tables - WHERE table_schema = DATABASE() - AND table_name = ? - LIMIT 1`, - [PREFS_TABLE] - ); - if (existing.length > 0) { - return; - } - await database.local.query( - `CREATE TABLE IF NOT EXISTS ${PREFS_TABLE} ( - user_id BIGINT PRIMARY KEY COMMENT '用户ID', - prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算用户个性化默认值'` - ); - })(); - } - try { - await prefsTableReady; - } catch (error) { - prefsTableReady = null; - throw error; - } -} - -// 允许的偏好字段与取值范围(targetMargin 为小数,0.15 = 15%) -const PREF_FIELD_RANGES = { - coupon: { min: 0, max: 100000 }, - activityDiscount: { min: 0.01, max: 1 }, - installFee: { min: 0, max: 100000 }, - targetMargin: { min: -1, max: 1 }, -}; - -function sanitizePrefs(input) { - const src = input && typeof input === 'object' ? input : {}; - const prefs = {}; - Object.entries(PREF_FIELD_RANGES).forEach(([key, range]) => { - const num = Number(src[key]); - if (Number.isFinite(num)) { - prefs[key] = Math.min(range.max, Math.max(range.min, num)); - } - }); - return prefs; -} - -const PREFS_PROFILE_KEY = '_profiles'; -const PREFS_PROFILES = new Set(['company', 'alibaba1688']); - -function normalizePrefsProfile(value) { - return PREFS_PROFILES.has(value) ? value : 'company'; -} - -function parsePrefsStorage(value) { - try { - const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value; - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -function readPrefsProfile(storageValue, profile = 'company') { - const storage = parsePrefsStorage(storageValue); - const normalizedProfile = normalizePrefsProfile(profile); - const source = normalizedProfile === 'company' - ? storage - : storage[PREFS_PROFILE_KEY]?.[normalizedProfile]; - const prefs = sanitizePrefs(source); - return Object.keys(prefs).length > 0 ? prefs : null; -} - -function writePrefsProfile(storageValue, profile, prefs) { - const storage = parsePrefsStorage(storageValue); - const normalizedProfile = normalizePrefsProfile(profile); - if (normalizedProfile === 'company') { - return { ...storage, ...prefs }; - } - return { - ...storage, - [PREFS_PROFILE_KEY]: { - ...(storage[PREFS_PROFILE_KEY] && typeof storage[PREFS_PROFILE_KEY] === 'object' - ? storage[PREFS_PROFILE_KEY] - : {}), - [normalizedProfile]: prefs, - }, - }; -} - -function createPrefsPatch(profile, prefs) { - const normalizedProfile = normalizePrefsProfile(profile); - return normalizedProfile === 'company' - ? prefs - : { [PREFS_PROFILE_KEY]: { [normalizedProfile]: prefs } }; -} - -async function persistPrefsProfile(userId, profile, prefs) { - const patch = JSON.stringify(createPrefsPatch(profile, prefs)); - try { - // JSON_MERGE_PATCH 在数据库内原子合并,保留另一测算类型的配置,且只需一次网络往返。 - await database.local.query( - `INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?) - ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH( - IF(JSON_VALID(prefs_json), prefs_json, '{}'), - VALUES(prefs_json) - )`, - [userId, patch] - ); - } catch (error) { - const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET'] - .includes(error.code); - if (!canFallback) throw error; - - // 兼容不支持 JSON_MERGE_PATCH 的旧版 MySQL / MariaDB。 - const existingRows = await database.local.query( - `SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`, - [userId] - ); - const storage = writePrefsProfile(existingRows[0]?.prefs_json, profile, prefs); - await database.local.query( - `INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?) - ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`, - [userId, JSON.stringify(storage)] - ); - } -} - -router.get('/prefs', async (req, res) => { - const userId = requireUserId(req, res); - if (!userId) return; - try { - await ensurePrefsTable(); - const rows = await database.local.query( - `SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`, - [userId] - ); - const profile = normalizePrefsProfile(req.query.profile); - const prefs = rows.length > 0 - ? readPrefsProfile(rows[0].prefs_json, profile) - : null; - res.json({ success: true, data: { prefs } }); - } catch (error) { - sendError(res, error, '读取个性化配置失败'); - } -}); - -router.put('/prefs', async (req, res) => { - const userId = requireUserId(req, res); - if (!userId) return; - const prefs = sanitizePrefs(req.body?.prefs ?? req.body); - const profile = normalizePrefsProfile(req.body?.profile); - if (Object.keys(prefs).length === 0) { - return res.status(400).json({ success: false, error: '缺少有效的配置项' }); - } - try { - await ensurePrefsTable(); - await persistPrefsProfile(userId, profile, prefs); - res.json({ success: true, data: { prefs } }); - } catch (error) { - sendError(res, error, '保存个性化配置失败'); - } -}); - -module.exports = router; -module.exports._test = { - filterSkuRowsForSelectionContext, - createPrefsPatch, - getSelectionSkuCodeSet, - normalizeCalculatorType, - readPrefsProfile, - sanitizeHistoryProduct, - sanitizePrefs, - writePrefsProfile, -}; diff --git a/backend/src/api/pricingCalculatorExternal.js b/backend/src/api/pricingCalculatorExternal.js deleted file mode 100644 index 60aee5f..0000000 --- a/backend/src/api/pricingCalculatorExternal.js +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; - -const crypto = require('crypto'); -const express = require('express'); -const pricingCalculatorRoutes = require('./pricingCalculator'); -const { - requireDesktopPageAccess, - requireExternalAutomationApiKey, -} = require('../services/externalAutomationAuth'); - -const router = express.Router(); -const MAX_OPERATOR_NAME_LENGTH = 100; -const MAX_SAFE_ID = BigInt(Number.MAX_SAFE_INTEGER); - -function normalizeOperatorName(value) { - return String(value || '').trim().slice(0, MAX_OPERATOR_NAME_LENGTH); -} - -function decodeOperatorNameHeader(value) { - const encoded = String(value || '').trim(); - if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return ''; - try { - return Buffer.from(encoded, 'base64url').toString('utf8'); - } catch { - return ''; - } -} - -function desktopPricingUserId(operatorName) { - const normalized = normalizeOperatorName(operatorName).toLocaleLowerCase('zh-CN'); - if (!normalized) return null; - const digest = crypto.createHash('sha256').update(`desktop-pricing:${normalized}`, 'utf8').digest(); - const value = (digest.readBigUInt64BE(0) % (MAX_SAFE_ID - 1n)) + 1n; - return -Number(value); -} - -function requireDesktopPricingOperator(req, res, next) { - if (req.externalApiKey?.authType === 'jwt' && req.user?.name) { - return next(); - } - - const operatorName = normalizeOperatorName( - decodeOperatorNameHeader(req.get('x-operator-name-encoded')) - || req.get('x-operator-name') - || req.query?.operatorName - || req.body?.operatorName - ); - if (!operatorName) { - return res.status(400).json({ success: false, error: '缺少运营花名' }); - } - - const userId = desktopPricingUserId(operatorName); - req.user = { - id: userId, - sub: userId, - name: operatorName, - username: operatorName, - role: 'operator', - authSource: 'desktop-api-key', - }; - return next(); -} - -router.use( - requireExternalAutomationApiKey, - requireDesktopPageAccess('planning_upload'), - requireDesktopPricingOperator, - pricingCalculatorRoutes -); - -module.exports = router; -module.exports.desktopPricingUserId = desktopPricingUserId; -module.exports.requireDesktopPricingOperator = requireDesktopPricingOperator; diff --git a/backend/src/api/productAnalysis.js b/backend/src/api/productAnalysis.js index dc2f15a..464abf8 100644 --- a/backend/src/api/productAnalysis.js +++ b/backend/src/api/productAnalysis.js @@ -9,7 +9,7 @@ const router = express.Router(); const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily'; const PRODUCT_SPREAD_TABLE = 'pdd_ReportGoodsSpread'; -const USER_PREFS_TABLE = 'pricing_calculator_user_prefs'; +const USER_PREFS_TABLE = 'product_analysis_user_prefs'; const PRODUCT_SIGNAL_PREFS_KEY = 'productAnalysisSignalThresholds'; const PRODUCT_META_CACHE_TTL_MS = 5 * 60 * 1000; const PRODUCT_SOURCE_CACHE_TTL_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_SOURCE_CACHE_TTL_MS) || 60 * 1000, 10 * 1000); diff --git a/backend/src/api/storeCheckup.js b/backend/src/api/storeCheckup.js deleted file mode 100644 index 3257492..0000000 --- a/backend/src/api/storeCheckup.js +++ /dev/null @@ -1,999 +0,0 @@ -const express = require('express'); -const jianSunDb = require('../config/jianSunDatabase'); - -const router = express.Router(); -const META_CACHE_TTL_MS = 60 * 1000; -const OVERVIEW_CACHE_TTL_MS = 60 * 1000; -const HOME_SUMMARY_CACHE_TTL_MS = 10 * 60 * 1000; -const HOME_SUMMARY_STALE_TTL_MS = 24 * 60 * 60 * 1000; -const HOME_COMPENSATION_CACHE_TTL_MS = 10 * 60 * 1000; -const HOME_COMPENSATION_SAMPLE_SIZE = 10000; -const metaCache = { value: null, expiresAt: 0, promise: null }; -const overviewCache = new Map(); -const overviewInFlight = new Map(); -const homeSummaryCache = new Map(); -const homeSummaryInFlight = new Map(); -const homeReviewCache = new Map(); -const homeReviewInFlight = new Map(); -const homeCompensationCache = new Map(); -const homeCompensationSummaryCache = new Map(); -const homeCompensationSummaryInFlight = new Map(); - -const TAB_KEYS = new Set(['overview', 'business', 'after-sales', 'customer-service', 'service-score', 'stars']); - -function normalizeDate(value) { - const text = String(value || '').trim(); - return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''; -} - -function addDays(value, offset) { - const date = new Date(`${value}T00:00:00Z`); - date.setUTCDate(date.getUTCDate() + offset); - return date.toISOString().slice(0, 10); -} - -function inclusiveDayCount(startDate, endDate) { - const start = new Date(`${startDate}T00:00:00Z`); - const end = new Date(`${endDate}T00:00:00Z`); - return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1; -} - -function toNumber(value) { - const number = Number(value); - return Number.isFinite(number) ? number : 0; -} - -function normalizeCompensationDate(value) { - const text = String(value || '').trim(); - return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''; -} - -function compensationDate(value) { - const text = String(value || '').trim(); - return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : ''; -} - -async function loadRecentCompensationRows(shopName = '') { - const cacheKey = shopName || '*'; - const cached = homeCompensationCache.get(cacheKey); - if (cached?.rows && cached.expiresAt > Date.now()) return cached.rows; - if (cached?.promise) return cached.promise; - - const promise = jianSunDb.query( - `SELECT TOP (${HOME_COMPENSATION_SAMPLE_SIZE}) - id, chargeAmount, chargeReason, chargeTime, couponDetailId, - goodsId, goodsName, orderSn, shopName - FROM dbo.pdd_abnormal_compensation WITH (NOLOCK) - WHERE (@shopName = '' OR shopName = @shopName) - ORDER BY id DESC`, - { shopName } - ).then((rows) => { - const normalizedRows = rows - .map((row) => ({ - id: row.id, - date: compensationDate(row.chargeTime), - amount: toNumber(row.chargeAmount), - reason: String(row.chargeReason || '').trim() || '未分类', - goodsId: String(row.goodsId || '').trim(), - goodsName: String(row.goodsName || '').trim(), - orderSn: String(row.orderSn || '').trim(), - detailId: String(row.couponDetailId || '').trim(), - shopName: String(row.shopName || '').trim(), - })) - .filter((row) => row.date); - homeCompensationCache.set(cacheKey, { - rows: normalizedRows, - expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS, - promise: null, - }); - if (homeCompensationCache.size > 12) { - const firstKey = homeCompensationCache.keys().next().value; - homeCompensationCache.delete(firstKey); - } - return normalizedRows; - }).finally(() => { - const entry = homeCompensationCache.get(cacheKey); - if (entry?.promise) homeCompensationCache.delete(cacheKey); - }); - homeCompensationCache.set(cacheKey, { rows: null, expiresAt: 0, promise }); - return promise; -} - -async function resolveCompensationDateRange(req, rows) { - const latestAvailableDate = rows.reduce((latest, row) => (row.date > latest ? row.date : latest), ''); - const endDate = normalizeCompensationDate(req.query.endDate) || latestAvailableDate; - const startDate = normalizeCompensationDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : ''); - if (!startDate || !endDate) { - const error = new Error('赔付数据没有可用日期'); - error.statusCode = 404; - throw error; - } - const dayCount = inclusiveDayCount(startDate, endDate); - if (dayCount < 1) { - const error = new Error('赔付开始日期不能晚于结束日期'); - error.statusCode = 400; - throw error; - } - if (dayCount > 90) { - const error = new Error('赔付查询日期范围不能超过90天'); - error.statusCode = 400; - throw error; - } - return { startDate, endDate, dayCount, latestAvailableDate }; -} - -function createCompensationGroup(row) { - return { - amount: 0, - recordCount: 0, - orderKeys: new Set(), - ...row, - }; -} - -function addCompensationRecord(group, row) { - group.amount += row.amount; - group.recordCount += 1; - group.orderKeys.add(row.orderSn || row.detailId || String(row.id)); -} - -function queryHomeCompensationSummary(rows, range) { - const selectedRows = rows.filter((row) => row.date >= range.startDate && row.date <= range.endDate); - const orderKeys = new Set(); - const goodsKeys = new Set(); - const reasonGroups = new Map(); - const goodsGroups = new Map(); - let totalAmount = 0; - - selectedRows.forEach((row) => { - const orderKey = row.orderSn || row.detailId || String(row.id); - orderKeys.add(orderKey); - if (row.goodsId) goodsKeys.add(row.goodsId); - totalAmount += row.amount; - - const reasonGroup = reasonGroups.get(row.reason) || createCompensationGroup({ reason: row.reason }); - addCompensationRecord(reasonGroup, row); - reasonGroups.set(row.reason, reasonGroup); - - const goodsKey = row.goodsId || `unknown-${row.id}`; - const goodsGroup = goodsGroups.get(goodsKey) || createCompensationGroup({ - goodsId: row.goodsId || '未识别商品ID', - goodsName: row.goodsName, - shopName: row.shopName, - }); - addCompensationRecord(goodsGroup, row); - if (!goodsGroup.goodsName && row.goodsName) goodsGroup.goodsName = row.goodsName; - if (!goodsGroup.shopName && row.shopName) goodsGroup.shopName = row.shopName; - goodsGroups.set(goodsKey, goodsGroup); - }); - - const toGroupResult = (group) => ({ - ...group, - orderCount: group.orderKeys.size, - amount: Number(group.amount.toFixed(2)), - share: totalAmount > 0 ? Number((group.amount * 100 / totalAmount).toFixed(2)) : 0, - orderKeys: undefined, - }); - const reasonDistribution = [...reasonGroups.values()] - .map(toGroupResult) - .sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount) - .slice(0, 6); - const highCompensationGoods = [...goodsGroups.values()] - .map(toGroupResult) - .sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount) - .slice(0, 6); - - return { - ...range, - sampledRecordCount: rows.length, - selectedRecordCount: selectedRows.length, - compensationCount: orderKeys.size, - recordCount: selectedRows.length, - totalAmount: Number(totalAmount.toFixed(2)), - averageAmount: orderKeys.size ? Number((totalAmount / orderKeys.size).toFixed(2)) : 0, - affectedGoodsCount: goodsKeys.size, - reasonDistribution, - highCompensationGoods, - }; -} - -function toNullableNumber(value) { - if (value === null || value === undefined || value === '') return null; - const number = Number(value); - return Number.isFinite(number) ? number : null; -} - -function averageNullable(rows, field) { - const values = rows.map((row) => toNullableNumber(row[field])).filter((value) => value !== null); - return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null; -} - -function cleanShopName(value) { - const text = String(value || '').trim(); - return text || ''; -} - -function shopFilterSql() { - return '(@shopName IS NULL OR @shopName = \'\' OR shopName = @shopName)'; -} - -async function loadMeta() { - if (metaCache.value && metaCache.expiresAt > Date.now()) return metaCache.value; - if (metaCache.promise) return metaCache.promise; - - metaCache.promise = Promise.all([ - jianSunDb.query( - `SELECT - shopName, - CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate, - CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate - FROM dbo.pdd_DataNormal - WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' - GROUP BY shopName - ORDER BY shopName` - ), - jianSunDb.query( - `SELECT - CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate, - CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate - FROM dbo.pdd_DataNormal - WHERE statDate IS NOT NULL` - ), - ]).then(([shopRows, dateRows]) => { - const shops = shopRows - .map((row) => ({ - name: cleanShopName(row.shopName), - minDate: normalizeDate(row.minDate), - latestDate: normalizeDate(row.latestDate), - })) - .filter((row) => row.name); - const globalDate = dateRows[0] || {}; - const data = { - shops, - minDate: normalizeDate(globalDate.minDate), - latestDate: normalizeDate(globalDate.latestDate), - }; - metaCache.value = data; - metaCache.expiresAt = Date.now() + META_CACHE_TTL_MS; - return data; - }).finally(() => { - metaCache.promise = null; - }); - - return metaCache.promise; -} - -async function resolveContext(req) { - const meta = await loadMeta(); - const shopName = cleanShopName(req.query.shopName); - const shop = shopName ? meta.shops.find((item) => item.name === shopName) : null; - const requestedDate = normalizeDate(req.query.date); - const date = requestedDate || shop?.latestDate || meta.latestDate; - return { meta, shopName, date }; -} - -async function queryLatestRows({ table, dateColumn, fields, targetDate, shopName }) { - const rows = await jianSunDb.query( - `WITH ranked AS ( - SELECT ${fields}, - ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY id DESC) AS rowNumber - FROM dbo.${table} - WHERE ${shopFilterSql()} - AND [${dateColumn}] = @targetDate - ) - SELECT * FROM ranked WHERE rowNumber = 1`, - { targetDate, shopName: shopName || null } - ); - return rows; -} - -async function queryBusinessSnapshot(targetDate, shopName) { - const rows = await queryLatestRows({ - table: 'pdd_DataNormal', - dateColumn: 'statDate', - targetDate, - shopName, - fields: '[id], shopName, statDate, cfmOrdrAmt, payOrdrAup, payUvRto', - }); - const sales = rows.reduce((sum, row) => sum + toNumber(row.cfmOrdrAmt), 0); - const averageConversion = averageNullable(rows, 'payUvRto'); - const count = rows.length; - return { - shopCount: count, - sales, - averageAov: averageNullable(rows, 'payOrdrAup'), - averageConversion: averageConversion === null ? null : averageConversion * 100, - rows, - }; -} - -async function queryAfterSalesSnapshot(targetDate, shopName) { - const rows = await queryLatestRows({ - table: 'pdd_ShopNormalOverflow', - dateColumn: 'createTime', - targetDate, - shopName, - fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt', - }); - const fields = ['executedCount', 'afterSalesCount', 'waitSellerHandle', 'platformHandling', 'waitBuyerHandle', 'todosCount', 'todosEndedCount', 'cfmOrdrCnt']; - const data = fields.reduce((result, field) => ({ ...result, [field]: rows.reduce((sum, row) => sum + toNumber(row[field]), 0) }), {}); - return { ...data, shopCount: rows.length, rows }; -} - -async function queryCustomerServiceSnapshot(targetDate, shopName) { - const [refundRows, receptionRows] = await Promise.all([ - queryLatestRows({ - table: 'pdd_DataCustomerService', - dateColumn: 'createTime', - targetDate, - shopName, - fields: '[id], shopName, createTime, dsptRfSucOrdrCnt1m, dsptRfSucRto1m, pltInvlOrdrCnt1m, pltInvlOrdrRto1m, qurfOrdRto1m, avgSucRfProcTime1m, ssslAvgSucRfProcTime1mPct, sucRfOrdrCnt1d, sucRfOrdrAmt1d, rfSucRto1m, avgSlfSucRfProcTime1mMgr, avgSlfSucRfProcTime1mMr', - }), - jianSunDb.query( - `WITH ranked AS ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber - FROM dbo.pdd_customer_overflow - WHERE ${shopFilterSql()} - AND [createTime] = @targetDate - ) - SELECT - SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount, - SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount, - SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount, - SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount, - SUM(ISNULL(need_manu_reply_consult_user_cnt, 0)) AS needManualReplyCount, - SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount, - SUM(ISNULL(delay_reply, 0)) AS delayReplyCount, - AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min, - AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s, - SUM(ISNULL(unsatisfied_serve_score_cnt, 0)) AS unsatisfiedScoreCount, - SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount, - AVG(TRY_CONVERT(decimal(18, 8), avg_reply_time)) AS averageReplyTime, - SUM(ISNULL(serve_refund_cnt, 0)) AS serviceRefundCount, - COUNT(DISTINCT cs_name) AS customerServiceCount - FROM ranked - WHERE rowNumber = 1 AND cs_name = N'店铺总计'`, - { targetDate, shopName: shopName || null } - ), - ]); - - const refundFields = [ - 'dsptRfSucOrdrCnt1m', 'pltInvlOrdrCnt1m', 'sucRfOrdrCnt1d', 'sucRfOrdrAmt1d', - ]; - const refundTotals = refundFields.reduce((result, field) => ({ - ...result, - [field]: refundRows.reduce((sum, row) => sum + toNumber(row[field]), 0), - }), {}); - const averageFields = [ - 'dsptRfSucRto1m', 'pltInvlOrdrRto1m', 'qurfOrdRto1m', 'avgSucRfProcTime1m', - 'ssslAvgSucRfProcTime1mPct', 'rfSucRto1m', 'avgSlfSucRfProcTime1mMgr', 'avgSlfSucRfProcTime1mMr', - ]; - const averages = averageFields.reduce((result, field) => ({ - ...result, - [field]: averageNullable(refundRows, field), - }), {}); - return { - ...refundTotals, - ...averages, - shopCount: refundRows.length, - reception: receptionRows[0] || {}, - rows: refundRows, - }; -} - -async function queryServiceScoreSnapshot(targetDate, shopName) { - const rows = await queryLatestRows({ - table: 'pdd_MallServiceScore', - dateColumn: 'createTime', - targetDate, - shopName, - fields: '[id], shopName, createTime, cstmrServScore, attiLmScore, jcfwLmScore, spLmScore, fhLmScore, wlLmScore', - }); - const fields = ['cstmrServScore', 'attiLmScore', 'jcfwLmScore', 'spLmScore', 'fhLmScore', 'wlLmScore']; - const values = fields.reduce((result, field) => ({ - ...result, - [field]: averageNullable(rows, field), - }), {}); - return { ...values, shopCount: rows.length, rows }; -} - -async function queryStarsSnapshot(targetDate, shopName) { - const rows = await queryLatestRows({ - table: 'pdd_ShopStars', - dateColumn: 'createTime', - targetDate, - shopName, - fields: '[id], shopName, createTime, mallStarTomms, mallStarTommsChange, mallStarDesc, ptHelpRate1m, aprsIn3minRplyUsrRto1m, rfSignTime1m, rfProcTime1m, dsr3m, posTypeRto1m, seriousInferiorRto1m, adjNfkAvgCfmSignTime1m, lgstMergeFine1m, gmvRk, ptHelpRate1mNextStarRkDesc, ptHelpRate1mNextPctThreshold, aprsIn3minRplyUsrRto1mNextStarRkDesc, aprsIn3minRplyUsrRto1mNextPctThreshold, rfSignTime1mNextStarRkDesc, rfSignTime1mNextPctThreshold, rfProcTime1mNextStarRkDesc, rfProcTime1mNextPctThreshold, dsr3mNextStarRkDesc, dsr3mNextPctThreshold, posTypeRto1mNextStarRkDesc, posTypeRto1mNextPctThreshold, seriousInferiorRto1mNextStarRkDesc, seriousInferiorRto1mNextPctThreshold, adjNfkAvgCfmSignTime1mNextStarRkDesc, adjNfkAvgCfmSignTime1mNextPctThreshold, lgstMergeFine1mNextStarRkDesc, lgstMergeFine1mNextPctThreshold, gmvRkNextStarRkDesc, gmvRkNextPctThreshold, scoreRk', - }); - const averageFields = ['mallStarTomms', 'mallStarTommsChange', 'ptHelpRate1m', 'aprsIn3minRplyUsrRto1m', 'rfSignTime1m', 'rfProcTime1m', 'dsr3m', 'posTypeRto1m', 'seriousInferiorRto1m', 'adjNfkAvgCfmSignTime1m', 'lgstMergeFine1m', 'gmvRk']; - const values = averageFields.reduce((result, field) => ({ - ...result, - [field]: averageNullable(rows, field), - }), {}); - const first = rows[0] || {}; - const targetFields = [ - ['ptHelpRate1m', '平台求助率', 'ptHelpRate1mNextStarRkDesc', 'ptHelpRate1mNextPctThreshold'], - ['aprsIn3minRplyUsrRto1m', '3分钟人工回复率', 'aprsIn3minRplyUsrRto1mNextStarRkDesc', 'aprsIn3minRplyUsrRto1mNextPctThreshold'], - ['rfSignTime1m', '在途订单退款时长', 'rfSignTime1mNextStarRkDesc', 'rfSignTime1mNextPctThreshold'], - ['rfProcTime1m', '签收退货后退款时长', 'rfProcTime1mNextStarRkDesc', 'rfProcTime1mNextPctThreshold'], - ['dsr3m', '90天用户评价得分', 'dsr3mNextStarRkDesc', 'dsr3mNextPctThreshold'], - ['posTypeRto1m', '积极评论率', 'posTypeRto1mNextStarRkDesc', 'posTypeRto1mNextPctThreshold'], - ['seriousInferiorRto1m', '严重劣质率', 'seriousInferiorRto1mNextStarRkDesc', 'seriousInferiorRto1mNextPctThreshold'], - ['adjNfkAvgCfmSignTime1m', '成团到签收时效', 'adjNfkAvgCfmSignTime1mNextStarRkDesc', 'adjNfkAvgCfmSignTime1mNextPctThreshold'], - ['lgstMergeFine1m', '物流综合处罚率', 'lgstMergeFine1mNextStarRkDesc', 'lgstMergeFine1mNextPctThreshold'], - ['gmvRk', '店铺活跃度', 'gmvRkNextStarRkDesc', 'gmvRkNextPctThreshold'], - ]; - const targets = shopName ? targetFields.map(([field, label, statusField, thresholdField]) => ({ - field, - label, - value: toNullableNumber(first[field]), - status: first[statusField] || null, - threshold: toNullableNumber(first[thresholdField]), - })) : []; - const targetFailureCount = rows.reduce((count, row) => count + targetFields.filter(([, , statusField]) => includesFailure(row[statusField])).length, 0); - return { ...values, shopCount: rows.length, targets, targetFailureCount, rows }; -} - -function includesFailure(value) { - const text = String(value || '').trim(); - return text.includes('不达标') || text.includes('未达标') || text.includes('下降'); -} - -function buildIssues({ afterSales, customerService, stars }) { - const issues = []; - const addIssue = (key, tone, title, value, description, source) => { - if (toNumber(value) > 0 || includesFailure(value)) { - issues.push({ key, tone, title, value, description, source }); - } - }; - addIssue('wait-seller-handle', 'error', '待商家处理售后单', afterSales.waitSellerHandle, '需要优先处理,避免售后时效继续积压', '店铺售后'); - addIssue('platform-handling', 'warning', '平台处理中售后单', afterSales.platformHandling, '平台介入中的售后订单需要关注处理结果', '店铺售后'); - addIssue('delay-reply', 'warning', '三分钟未回复咨询', customerService.reception.delayReplyCount, '客服响应存在延迟', '客服接待'); - addIssue('complaint', 'error', '客服投诉数', customerService.reception.complainUserCount, '存在客户投诉,需要回溯客服接待记录', '客服接待'); - addIssue('star-target', 'warning', '星级指标存在未达标项', stars.targetFailureCount, '可在“星级提升”中查看具体指标和下一星级标准', '店铺星级'); - return issues; -} - -async function queryOverview(context) { - const { shopName, date } = context; - const [business, afterSales, customerService, serviceScore, stars] = await Promise.all([ - queryBusinessSnapshot(date, shopName), - queryAfterSalesSnapshot(date, shopName), - queryCustomerServiceSnapshot(date, shopName), - queryServiceScoreSnapshot(date, shopName), - queryStarsSnapshot(date, shopName), - ]); - const reception = customerService.reception || {}; - return { - date, - shopName: shopName || '全部店铺', - shopCount: business.shopCount, - business: { - sales: business.sales, - averageAov: business.averageAov, - averageConversion: business.averageConversion, - }, - afterSales: { - afterSalesCount: afterSales.afterSalesCount, - executedCount: afterSales.executedCount, - waitSellerHandle: afterSales.waitSellerHandle, - platformHandling: afterSales.platformHandling, - waitBuyerHandle: afterSales.waitBuyerHandle, - todosCount: afterSales.todosCount, - todosEndedCount: afterSales.todosEndedCount, - cfmOrdrCnt: afterSales.cfmOrdrCnt, - }, - customerService: { - ...customerService, - reception: { - consultUserCount: toNumber(reception.consultUserCount), - inquiryUserCount: toNumber(reception.inquiryUserCount), - finalGroupUserCount: toNumber(reception.finalGroupUserCount), - csSalesAmount: toNumber(reception.csSalesAmount), - needManualReplyCount: toNumber(reception.needManualReplyCount), - receiveUserCount: toNumber(reception.receiveUserCount), - delayReplyCount: toNumber(reception.delayReplyCount), - replyRate3Min: toNullableNumber(reception.replyRate3Min), - replyRate30s: toNullableNumber(reception.replyRate30s), - unsatisfiedScoreCount: toNumber(reception.unsatisfiedScoreCount), - complainUserCount: toNumber(reception.complainUserCount), - averageReplyTime: toNullableNumber(reception.averageReplyTime), - serviceRefundCount: toNumber(reception.serviceRefundCount), - customerServiceCount: toNumber(reception.customerServiceCount), - }, - }, - serviceScore: { - cstmrServScore: serviceScore.cstmrServScore, - attiLmScore: serviceScore.attiLmScore, - jcfwLmScore: serviceScore.jcfwLmScore, - spLmScore: serviceScore.spLmScore, - fhLmScore: serviceScore.fhLmScore, - wlLmScore: serviceScore.wlLmScore, - }, - stars: { - mallStarTomms: stars.mallStarTomms, - mallStarTommsChange: stars.mallStarTommsChange, - dsr3m: stars.dsr3m, - ptHelpRate1m: stars.ptHelpRate1m, - aprsIn3minRplyUsrRto1m: stars.aprsIn3minRplyUsrRto1m, - targets: stars.targets, - }, - issues: buildIssues({ afterSales, customerService, stars }), - coverage: { - business: business.shopCount > 0, - afterSales: afterSales.shopCount > 0, - customerService: customerService.shopCount > 0 || Boolean(reception.customerServiceCount), - serviceScore: serviceScore.shopCount > 0, - stars: stars.shopCount > 0, - }, - }; -} - -async function queryHomeSummary(context) { - const { shopName, date } = context; - const [afterSales, customerService, serviceScore, stars] = await Promise.all([ - queryAfterSalesSnapshot(date, shopName), - queryCustomerServiceSnapshot(date, shopName), - queryServiceScoreSnapshot(date, shopName), - queryStarsSnapshot(date, shopName), - ]); - const reception = customerService.reception || {}; - const riskItems = [ - { - key: 'waitSellerHandle', - label: '待商家处理', - value: toNumber(afterSales.waitSellerHandle), - tone: 'error', - }, - { - key: 'delayReplyCount', - label: '三分钟未回复', - value: toNumber(reception.delayReplyCount), - tone: 'warning', - }, - { - key: 'complainUserCount', - label: '客服投诉', - value: toNumber(reception.complainUserCount), - tone: 'error', - }, - { - key: 'starTargetFailure', - label: '星级未达标项', - value: toNumber(stars.targetFailureCount), - tone: 'warning', - }, - ]; - const riskSignalCount = riskItems.reduce((sum, item) => sum + item.value, 0); - const riskTypeCount = riskItems.filter((item) => item.value > 0).length; - const coverageModules = [ - afterSales.shopCount > 0, - customerService.shopCount > 0 || Boolean(reception.customerServiceCount), - serviceScore.shopCount > 0, - stars.shopCount > 0, - ].filter(Boolean).length; - - return { - date, - shopName: shopName || '全部店铺', - shopCount: Math.max( - toNumber(afterSales.shopCount), - toNumber(customerService.shopCount), - toNumber(serviceScore.shopCount), - toNumber(stars.shopCount) - ), - totalShopCount: shopName ? 1 : context.meta.shops.length, - coverageModules, - totalModules: 4, - status: riskSignalCount > 0 ? 'attention' : 'healthy', - riskSignalCount, - riskTypeCount, - averageStar: toNullableNumber(stars.mallStarTomms), - starChange: toNullableNumber(stars.mallStarTommsChange), - evaluationScore: toNullableNumber(stars.dsr3m), - riskItems, - serviceScores: [ - { key: 'cstmrServScore', label: '服务体验', value: toNullableNumber(serviceScore.cstmrServScore) }, - { key: 'spLmScore', label: '商品服务', value: toNullableNumber(serviceScore.spLmScore) }, - { key: 'wlLmScore', label: '物流服务', value: toNullableNumber(serviceScore.wlLmScore) }, - { key: 'fhLmScore', label: '发货服务', value: toNullableNumber(serviceScore.fhLmScore) }, - ], - }; -} - -async function resolveReviewDateRange(req) { - const shopName = cleanShopName(req.query.shopName); - let endDate = normalizeDate(req.query.endDate); - if (!endDate) { - const rows = await jianSunDb.query( - `SELECT TOP (1) LEFT(createTime, 10) AS latestDate - FROM dbo.pdd_Evaluation WITH (NOLOCK) - WHERE createTime >= '2000-01-01' - AND createTime < '2100-01-01' - AND descScore BETWEEN 1 AND 5 - AND (@shopName = '' OR shopName = @shopName) - ORDER BY createTime DESC`, - { shopName } - ); - endDate = normalizeDate(rows[0]?.latestDate); - } - const startDate = normalizeDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : ''); - if (!startDate || !endDate) { - const error = new Error('评价数据没有可用日期'); - error.statusCode = 404; - throw error; - } - const dayCount = inclusiveDayCount(startDate, endDate); - if (dayCount < 1) { - const error = new Error('评价开始日期不能晚于结束日期'); - error.statusCode = 400; - throw error; - } - if (dayCount > 90) { - const error = new Error('评价查询日期范围不能超过90天'); - error.statusCode = 400; - throw error; - } - return { startDate, endDate, dayCount, shopName }; -} - -async function queryHomeReviewSummary(range) { - const params = { - startDate: range.startDate, - endDateExclusive: addDays(range.endDate, 1), - shopName: range.shopName || '', - }; - const [summaryRows, keywordRows] = await Promise.all([ - jianSunDb.query( - `SELECT - COUNT_BIG(1) AS totalReviewCount, - SUM(CASE WHEN descScore IN (4, 5) THEN 1 ELSE 0 END) AS goodReviewCount, - SUM(CASE WHEN descScore IN (1, 2, 3) THEN 1 ELSE 0 END) AS badReviewCount, - SUM(CASE WHEN descScore = 1 THEN 1 ELSE 0 END) AS score1Count, - SUM(CASE WHEN descScore = 2 THEN 1 ELSE 0 END) AS score2Count, - SUM(CASE WHEN descScore = 3 THEN 1 ELSE 0 END) AS score3Count, - SUM(CASE WHEN descScore = 4 THEN 1 ELSE 0 END) AS score4Count, - SUM(CASE WHEN descScore = 5 THEN 1 ELSE 0 END) AS score5Count, - AVG(TRY_CONVERT(decimal(18, 6), descScore)) AS averageScore, - COUNT(DISTINCT goodsId) AS reviewedGoodsCount, - COUNT(DISTINCT NULLIF(LTRIM(RTRIM(shopName)), '')) AS reviewedShopCount - FROM dbo.pdd_Evaluation WITH (NOLOCK) - WHERE createTime >= @startDate - AND createTime < @endDateExclusive - AND descScore BETWEEN 1 AND 5 - AND (@shopName = '' OR shopName = @shopName)`, - params - ), - jianSunDb.query( - `SELECT keywords, COUNT_BIG(1) AS reviewCount - FROM dbo.pdd_Evaluation WITH (NOLOCK) - WHERE createTime >= @startDate - AND createTime < @endDateExclusive - AND descScore BETWEEN 1 AND 3 - AND (@shopName = '' OR shopName = @shopName) - AND NULLIF(LTRIM(RTRIM(keywords)), '') IS NOT NULL - GROUP BY keywords`, - params - ), - ]); - const row = summaryRows[0] || {}; - const totalReviewCount = toNumber(row.totalReviewCount); - const goodReviewCount = toNumber(row.goodReviewCount); - const badReviewCount = toNumber(row.badReviewCount); - const scoreCounts = [1, 2, 3, 4, 5].map((score) => ({ - score, - count: toNumber(row[`score${score}Count`]), - })); - const negativeKeywordPattern = /差|不符|异味|瑕疵|破损|低|少发|错发|不好|问题|慢|难/; - const badKeywordCounts = new Map(); - keywordRows.forEach((item) => { - const weight = toNumber(item.reviewCount); - String(item.keywords || '').split(/[,,]/).forEach((value) => { - const keyword = value.trim(); - if (!keyword || !negativeKeywordPattern.test(keyword)) return; - badKeywordCounts.set(keyword, (badKeywordCounts.get(keyword) || 0) + weight); - }); - }); - const badKeywords = [...badKeywordCounts.entries()] - .map(([keyword, count]) => ({ keyword, count })) - .sort((left, right) => right.count - left.count || left.keyword.localeCompare(right.keyword, 'zh-CN')) - .slice(0, 5); - - return { - ...range, - totalReviewCount, - goodReviewCount, - goodReviewRate: totalReviewCount > 0 ? goodReviewCount * 100 / totalReviewCount : 0, - badReviewCount, - badReviewRate: totalReviewCount > 0 ? badReviewCount * 100 / totalReviewCount : 0, - averageScore: toNullableNumber(row.averageScore), - reviewedGoodsCount: toNumber(row.reviewedGoodsCount), - reviewedShopCount: toNumber(row.reviewedShopCount), - distribution: scoreCounts.map((item) => ({ - ...item, - rate: totalReviewCount > 0 ? item.count * 100 / totalReviewCount : 0, - })), - badKeywords, - }; -} - -async function queryBusinessTab(context) { - const startDate = addDays(context.date, -29); - const rows = await jianSunDb.query( - `WITH ranked AS ( - SELECT - statDate, - shopName, cfmOrdrAmt, payOrdrAup, payUvRto, - ROW_NUMBER() OVER (PARTITION BY shopName, statDate ORDER BY id DESC) AS rowNumber - FROM dbo.pdd_DataNormal - WHERE ${shopFilterSql()} - AND statDate BETWEEN @startDate AND @targetDate - ) - SELECT statDate, - SUM(TRY_CONVERT(decimal(38, 6), cfmOrdrAmt)) AS sales, - AVG(TRY_CONVERT(decimal(38, 6), payOrdrAup)) AS averageAov, - AVG(TRY_CONVERT(decimal(38, 8), payUvRto)) AS averageConversion, - COUNT(*) AS shopCount - FROM ranked - WHERE rowNumber = 1 - GROUP BY statDate - ORDER BY statDate`, - { startDate, targetDate: context.date, shopName: context.shopName || '' } - ); - return { - status: rows.length ? 'ready' : 'empty', - metrics: [ - { key: 'sales', label: '成交额', value: rows.reduce((sum, row) => sum + toNumber(row.sales), 0), unit: 'currency' }, - { key: 'averageAov', label: '平均支付客单价', value: rows.length ? toNumber(rows.at(-1).averageAov) : null, unit: 'currency' }, - { key: 'averageConversion', label: '最新支付转化率', value: rows.length ? toNumber(rows.at(-1).averageConversion) * 100 : null, unit: 'percent' }, - ], - trend: rows.map((row) => ({ date: row.statDate, sales: toNumber(row.sales), averageAov: toNumber(row.averageAov), averageConversion: toNumber(row.averageConversion) * 100 })), - }; -} - -async function queryAfterSalesTab(context) { - const rows = await queryLatestRows({ - table: 'pdd_ShopNormalOverflow', - dateColumn: 'createTime', - targetDate: context.date, - shopName: context.shopName, - fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt', - }); - return { - status: rows.length ? 'ready' : 'empty', - rows, - columns: [ - { key: 'shopName', title: '店铺' }, { key: 'afterSalesCount', title: '售后总量' }, { key: 'executedCount', title: '已处理' }, - { key: 'waitSellerHandle', title: '待商家处理' }, { key: 'platformHandling', title: '平台处理中' }, { key: 'waitBuyerHandle', title: '待买家处理' }, - { key: 'todosCount', title: '工单总量' }, { key: 'todosEndedCount', title: '已完结工单' }, - ], - }; -} - -async function queryCustomerServiceTab(context) { - const rows = await jianSunDb.query( - `WITH ranked AS ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber - FROM dbo.pdd_customer_overflow - WHERE ${shopFilterSql()} - AND [createTime] = @targetDate - ) - SELECT - cs_name AS csName, - SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount, - SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount, - SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount, - SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount, - SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount, - SUM(ISNULL(delay_reply, 0)) AS delayReplyCount, - AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min, - AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s, - SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount - FROM ranked - WHERE rowNumber = 1 AND ISNULL(cs_name, '') <> N'店铺总计' - GROUP BY cs_name - ORDER BY SUM(ISNULL(cs_sales_amount, 0)) DESC`, - { targetDate: context.date, shopName: context.shopName || '' } - ); - return { - status: rows.length ? 'ready' : 'empty', - rows, - columns: [ - { key: 'csName', title: '客服账号' }, { key: 'consultUserCount', title: '咨询人数' }, { key: 'inquiryUserCount', title: '询单人数' }, - { key: 'finalGroupUserCount', title: '成团人数' }, { key: 'csSalesAmount', title: '客服销售额' }, { key: 'receiveUserCount', title: '人工接待' }, - { key: 'replyRate30s', title: '30秒应答率' }, { key: 'replyRate3Min', title: '3分钟回复率' }, { key: 'complainUserCount', title: '投诉数' }, - ], - }; -} - -async function queryServiceScoreTab(context) { - const snapshot = await queryServiceScoreSnapshot(context.date, context.shopName); - return { - status: snapshot.shopCount ? 'ready' : 'empty', - metrics: [ - { key: 'cstmrServScore', label: '消费者服务体验分', value: snapshot.cstmrServScore, unit: 'score' }, - { key: 'attiLmScore', label: '服务态度', value: snapshot.attiLmScore, unit: 'score' }, - { key: 'jcfwLmScore', label: '基础服务', value: snapshot.jcfwLmScore, unit: 'score' }, - { key: 'spLmScore', label: '商品服务', value: snapshot.spLmScore, unit: 'score' }, - { key: 'fhLmScore', label: '发货服务', value: snapshot.fhLmScore, unit: 'score' }, - { key: 'wlLmScore', label: '物流服务', value: snapshot.wlLmScore, unit: 'score' }, - ], - rows: snapshot.rows, - columns: [ - { key: 'shopName', title: '店铺' }, { key: 'cstmrServScore', title: '消费者服务体验分' }, { key: 'attiLmScore', title: '服务态度' }, - { key: 'jcfwLmScore', title: '基础服务' }, { key: 'spLmScore', title: '商品服务' }, { key: 'fhLmScore', title: '发货服务' }, { key: 'wlLmScore', title: '物流服务' }, - ], - }; -} - -async function queryStarsTab(context) { - const snapshot = await queryStarsSnapshot(context.date, context.shopName); - return { - status: snapshot.shopCount ? 'ready' : 'empty', - metrics: [ - { key: 'mallStarTomms', label: '综合体验星级', value: snapshot.mallStarTomms, unit: 'star' }, - { key: 'mallStarTommsChange', label: '星级变化', value: snapshot.mallStarTommsChange, unit: 'starChange' }, - { key: 'dsr3m', label: '90天用户评价得分', value: snapshot.dsr3m, unit: 'score' }, - { key: 'ptHelpRate1m', label: '30天平台求助率', value: snapshot.ptHelpRate1m, unit: 'ratio' }, - { key: 'aprsIn3minRplyUsrRto1m', label: '30天3分钟人工回复率', value: snapshot.aprsIn3minRplyUsrRto1m, unit: 'ratio' }, - ], - targets: snapshot.targets, - rows: snapshot.rows, - columns: [ - { key: 'shopName', title: '店铺' }, { key: 'mallStarTomms', title: '综合体验星级' }, { key: 'mallStarTommsChange', title: '星级变化' }, - { key: 'dsr3m', title: '90天评价得分' }, { key: 'ptHelpRate1m', title: '平台求助率' }, { key: 'aprsIn3minRplyUsrRto1m', title: '3分钟回复率' }, - ], - }; -} - -router.get('/meta', async (_req, res) => { - try { - res.json({ success: true, data: await loadMeta() }); - } catch (error) { - console.error('[StoreCheckup] 查询元数据失败:', error.message); - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.get('/overview', async (req, res) => { - try { - const context = await resolveContext(req); - const cacheKey = `${context.shopName || '*'}:${context.date}`; - const cached = overviewCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return res.json({ success: true, data: cached.data }); - } - let pending = overviewInFlight.get(cacheKey); - if (!pending) { - pending = queryOverview(context) - .then((data) => { - overviewCache.set(cacheKey, { data, expiresAt: Date.now() + OVERVIEW_CACHE_TTL_MS }); - return data; - }) - .finally(() => overviewInFlight.delete(cacheKey)); - overviewInFlight.set(cacheKey, pending); - } - const data = await pending; - res.json({ success: true, data }); - } catch (error) { - console.error('[StoreCheckup] 查询体检概览失败:', error.message); - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.get('/home-summary', async (req, res) => { - try { - const context = await resolveContext(req); - const cacheKey = `${context.shopName || '*'}:${context.date}`; - const cached = homeSummaryCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return res.json({ success: true, data: cached.data }); - } - const refreshSummary = () => { - let pending = homeSummaryInFlight.get(cacheKey); - if (pending) return pending; - pending = queryHomeSummary(context) - .then((data) => { - const now = Date.now(); - homeSummaryCache.set(cacheKey, { - data, - expiresAt: now + HOME_SUMMARY_CACHE_TTL_MS, - staleUntil: now + HOME_SUMMARY_STALE_TTL_MS, - }); - return data; - }) - .finally(() => homeSummaryInFlight.delete(cacheKey)); - homeSummaryInFlight.set(cacheKey, pending); - return pending; - }; - if (cached && cached.staleUntil > Date.now()) { - refreshSummary().catch((error) => { - console.error('[StoreCheckup] 后台刷新首页摘要失败:', error.message); - }); - return res.json({ success: true, data: { ...cached.data, refreshing: true } }); - } - return res.json({ success: true, data: await refreshSummary() }); - } catch (error) { - console.error('[StoreCheckup] 查询首页摘要失败:', error.message); - return res.status(500).json({ success: false, error: error.message }); - } -}); - -router.get('/home-reviews', async (req, res) => { - try { - const range = await resolveReviewDateRange(req); - const cacheKey = `${range.shopName || '*'}:${range.startDate}:${range.endDate}`; - const cached = homeReviewCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return res.json({ success: true, data: cached.data }); - } - let pending = homeReviewInFlight.get(cacheKey); - if (!pending) { - pending = queryHomeReviewSummary(range) - .then((data) => { - homeReviewCache.set(cacheKey, { data, expiresAt: Date.now() + HOME_SUMMARY_CACHE_TTL_MS }); - return data; - }) - .finally(() => homeReviewInFlight.delete(cacheKey)); - homeReviewInFlight.set(cacheKey, pending); - } - return res.json({ success: true, data: await pending }); - } catch (error) { - console.error('[StoreCheckup] 查询首页评价摘要失败:', error.message); - return res.status(error.statusCode || 500).json({ success: false, error: error.message }); - } -}); - -router.get('/home-compensation', async (req, res) => { - try { - const shopName = cleanShopName(req.query.shopName); - const rows = await loadRecentCompensationRows(shopName); - const range = { ...(await resolveCompensationDateRange(req, rows)), shopName }; - const cacheKey = `${shopName || '*'}:${range.startDate}:${range.endDate}`; - const cached = homeCompensationSummaryCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return res.json({ success: true, data: cached.data }); - } - let pending = homeCompensationSummaryInFlight.get(cacheKey); - if (!pending) { - pending = Promise.resolve(queryHomeCompensationSummary(rows, range)) - .then((data) => { - homeCompensationSummaryCache.set(cacheKey, { - data, - expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS, - }); - return data; - }) - .finally(() => homeCompensationSummaryInFlight.delete(cacheKey)); - homeCompensationSummaryInFlight.set(cacheKey, pending); - } - return res.json({ success: true, data: await pending }); - } catch (error) { - console.error('[StoreCheckup] 查询首页赔付摘要失败:', error.message); - return res.status(error.statusCode || 500).json({ success: false, error: error.message }); - } -}); - -router.get('/tabs/:tabKey', async (req, res) => { - try { - const tabKey = String(req.params.tabKey || '').trim(); - if (!TAB_KEYS.has(tabKey) || tabKey === 'overview') { - return res.status(400).json({ success: false, error: '不支持的店铺体检模块' }); - } - const context = await resolveContext(req); - const handlers = { - business: queryBusinessTab, - 'after-sales': queryAfterSalesTab, - 'customer-service': queryCustomerServiceTab, - 'service-score': queryServiceScoreTab, - stars: queryStarsTab, - }; - const data = await handlers[tabKey](context); - return res.json({ success: true, data: { ...data, date: context.date, shopName: context.shopName || '全部店铺' } }); - } catch (error) { - console.error('[StoreCheckup] 查询体检模块失败:', error.message); - return res.status(500).json({ success: false, error: error.message }); - } -}); - -module.exports = router; diff --git a/backend/src/constants/sidebarPages.js b/backend/src/constants/sidebarPages.js index 478c23f..0546a50 100644 --- a/backend/src/constants/sidebarPages.js +++ b/backend/src/constants/sidebarPages.js @@ -29,11 +29,6 @@ const SIDEBAR_PAGES = [ buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }), buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }), buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }), - buildPlanningPage({ pageKey: 'store_checkup', pageName: '店铺体检', path: '/planning/operation/store-checkup' }), - buildPlanningPage({ pageKey: 'review_analysis', pageName: '复盘分析', path: '/planning/operation/review-analysis' }), - buildPlanningPage({ pageKey: 'customer_service_checkup', pageName: '客服质检', path: '/planning/operation/customer-service-checkup' }), - buildPlanningPage({ pageKey: 'ai_buyer_show', pageName: 'AI买家秀', path: '/planning/operation/ai-buyer-show' }), - buildPlanningPage({ pageKey: 'ai_seeding_video', pageName: 'AI种草视频', path: '/planning/operation/ai-seeding-video' }), buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }), { pageKey: 'settings', diff --git a/backend/src/index.js b/backend/src/index.js index 14934d8..5349b9a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -19,7 +19,6 @@ const planningSelectionFissionExternalRoutes = require('./api/planningSelectionF const presaleExternalRoutes = require('./api/presaleExternal'); const delistExternalRoutes = require('./api/delistExternal'); const campaignCloudRoutes = require('./api/campaignCloud'); -const pricingCalculatorExternalRoutes = require('./api/pricingCalculatorExternal'); const desktopClientRoutes = require('./api/desktopClient'); const desktopPresenceRoutes = require('./api/desktopPresence'); const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks'); @@ -33,10 +32,6 @@ const productCodeBoardRoutes = require('./api/productCodeBoard'); const productAnalysisRoutes = require('./api/productAnalysis'); const marketingEventsRoutes = require('./api/marketingEvents'); const promotionModuleRoutes = require('./api/promotionModule'); -const csQualityRoutes = require('./api/csQuality'); -const aiBuyerShowRoutes = require('./api/aiBuyerShow'); -const storeCheckupRoutes = require('./api/storeCheckup'); -const pricingCalculatorRoutes = require('./api/pricingCalculator'); const productMonitoringRoutes = require('./api/productMonitoring'); const database = require('./config/database'); const cloudDb = require('./config/cloudDatabase'); @@ -636,7 +631,6 @@ async function startServer(options = {}) { cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes); cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes); cubejsApp.use('/api/campaign', campaignCloudRoutes); - cubejsApp.use('/api/external/selection-fission/pricing-calculator', pricingCalculatorExternalRoutes); cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes); cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes); cubejsApp.use('/api/external/presale', presaleExternalRoutes); @@ -655,10 +649,6 @@ async function startServer(options = {}) { cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes); cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes); cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes); - cubejsApp.use('/api/cs-quality', authMiddleware, csQualityRoutes); - cubejsApp.use('/api/ai-buyer-show', authMiddleware, aiBuyerShowRoutes); - cubejsApp.use('/api/store-checkup', authMiddleware, storeCheckupRoutes); - cubejsApp.use('/api/pricing-calculator', authMiddleware, pricingCalculatorRoutes); cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); diff --git a/backend/src/services/aiBuyerShowReviewService.js b/backend/src/services/aiBuyerShowReviewService.js deleted file mode 100644 index 99e35b3..0000000 --- a/backend/src/services/aiBuyerShowReviewService.js +++ /dev/null @@ -1,375 +0,0 @@ -const axios = require('axios'); -const database = require('../config/database'); - -const DEFAULT_REVIEW_TOTAL = 10; -const REVIEW_TOTAL_OPTIONS = [5, 10, 20, 40, 60, 80, 100]; -const REVIEW_BATCH_SIZE = 5; -const REVIEW_MAX_CONCURRENCY = 4; -const REVIEW_MAX_REFILL_ATTEMPTS = 3; - -const SUPPORTED_SCENES = [ - '卧室', '书房', '客厅', '餐厅', '阳台', '入户门', '玄关', - '办公室', '户外', '厨房', '电竞房', -]; -const SUPPORTED_USER_TYPES = [ - '自己', '妈妈', '爸爸', '儿子', '女儿', '老公', '老婆', '男朋友', - '女朋友', '闺蜜', '朋友', '同事', '室友', '老人', '孩子', '学生', - '上班族', '租房党', '新婚家庭', '宝妈', '宠物家庭', '客户', '领导', -]; - -function normalizeMultiInput(value) { - const raw = Array.isArray(value) ? value : String(value || '').split(/[,,、\n\r]+/); - return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean))); -} - -function normalizeBoolean(value, fallback = true) { - if (value === undefined || value === null || value === '') return fallback; - if (typeof value === 'boolean') return value; - const normalized = String(value).trim().toLowerCase(); - if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; - if (['0', 'false', 'no', 'off'].includes(normalized)) return false; - return fallback; -} - -async function getSystemConfigMap(keys) { - try { - const placeholders = keys.map(() => '?').join(', '); - const rows = await database.local.query( - `SELECT config_key AS configKey, config_value AS configValue - FROM system_config - WHERE config_key IN (${placeholders})`, - keys - ); - return new Map(rows.map((row) => [row.configKey, String(row.configValue || '').trim()])); - } catch (_) { - return new Map(); - } -} - -async function getRuntimeConfig() { - const keys = [ - 'ai_buyer_show_ark_api_key', - 'ai_buyer_show_ark_base_url', - 'ai_buyer_show_vision_model', - 'ai_buyer_show_review_model', - ]; - const stored = await getSystemConfigMap(keys); - const apiKey = String(process.env.ARK_API_KEY || stored.get(keys[0]) || '').trim(); - if (!apiKey) { - const error = new Error('未配置豆包 ARK_API_KEY,无法生成攻心评价'); - error.statusCode = 503; - throw error; - } - const baseUrl = process.env.ARK_BASE_URL - || stored.get(keys[1]) - || 'https://ark.cn-beijing.volces.com/api/v3'; - const visionModel = process.env.VISION_MODEL - || stored.get(keys[2]) - || 'doubao-1-5-vision-pro-32k-250115'; - const reviewModel = process.env.PDD_HEART_REVIEW_MODEL - || process.env.DOUBAO_TEXT_MODEL - || stored.get(keys[3]) - || visionModel; - return { apiKey, baseUrl, visionModel, reviewModel }; -} - -function buildImageDataUrl(buffer, mimeType) { - const type = String(mimeType || '').startsWith('image/') ? mimeType : 'image/jpeg'; - return `data:${type};base64,${buffer.toString('base64')}`; -} - -function parseJsonContent(content, fallback) { - const trimmed = String(content || '') - .trim() - .replace(/^```json\s*/i, '') - .replace(/^```\s*/i, '') - .replace(/```$/i, '') - .trim(); - if (!trimmed) return fallback; - try { - return JSON.parse(trimmed); - } catch (_) { - const candidate = trimmed.match(/\[[\s\S]*\]/)?.[0] - || trimmed.match(/\{[\s\S]*\}/)?.[0]; - if (!candidate) return fallback; - try { - return JSON.parse(candidate); - } catch (_error) { - return fallback; - } - } -} - -async function callChatCompletion(messages, modelType, options = {}) { - const config = await getRuntimeConfig(); - const model = modelType === 'vision' ? config.visionModel : config.reviewModel; - try { - const response = await axios.post( - `${String(config.baseUrl).replace(/\/$/, '')}/chat/completions`, - { - model, - messages, - temperature: options.temperature ?? 0.68, - ...(options.maxTokens ? { max_tokens: options.maxTokens } : {}), - }, - { - headers: { - Authorization: `Bearer ${config.apiKey}`, - 'Content-Type': 'application/json', - }, - timeout: 180000, - } - ); - return { - content: response.data?.choices?.[0]?.message?.content || '', - model: response.data?.model || model, - }; - } catch (error) { - const detail = error.response?.data?.error?.message - || error.response?.data?.message - || error.response?.data?.error - || error.message - || 'AI接口调用失败'; - const wrapped = new Error(`AI接口调用失败:${detail}`); - wrapped.statusCode = error.response?.status || 502; - throw wrapped; - } -} - -async function analyzeImage({ imageBuffer, mimeType, scenes, userTypes, sellingPoints }) { - const prompt = [ - '你是拼多多商品评价运营助手。请识别商品图片,并结合运营填写的信息,严格返回 JSON 对象。', - '只返回 JSON,不要 markdown,不要解释。', - 'JSON 字段:productCategory、productNameGuess、furnitureColors、visualScene、visualSellingPoints、userPainPoints、emotionalAngles。', - `运营选择场景:${scenes.length ? scenes.join('、') : '未填写,请根据图片判断最合适场景'}`, - `运营选择使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`, - `运营填写卖点:${sellingPoints}`, - ].join('\n'); - const result = await callChatCompletion([{ - role: 'user', - content: [ - { type: 'text', text: prompt }, - { type: 'image_url', image_url: { url: buildImageDataUrl(imageBuffer, mimeType) } }, - ], - }], 'vision'); - return { analysis: parseJsonContent(result.content, {}), model: result.model }; -} - -function normalizeReviewText(value) { - return String(value || '').replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim(); -} - -function stripEmoji(value) { - return String(value || '') - .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '') - .replace(/\p{Extended_Pictographic}/gu, '') - .replace(/[\u{FE0E}\u{FE0F}\u{200D}]/gu, '') - .replace(/\s{2,}/g, ' ') - .trim(); -} - -function normalizeReviewItem(value, useEmoji) { - const source = typeof value === 'string' ? { reviewContent: value } : (value || {}); - const reviewContent = normalizeReviewText( - source.reviewContent || source.content || source.text || source.review || source['评价内容'] - ); - if (!reviewContent) return null; - return { - reviewContent: useEmoji ? reviewContent : stripEmoji(reviewContent), - userType: normalizeReviewText(source.userType || source.user || source.person || source['使用人']), - sellingPoint: normalizeReviewText(source.sellingPoint || source.selling_points || source.point || source['突出卖点']), - scene: normalizeReviewText(source.scene || source.usageScene || source['使用场景']), - }; -} - -function parseReviews(content, useEmoji) { - const parsed = parseJsonContent(content, null); - const source = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.reviews) ? parsed.reviews : []); - if (source.length) return source.map((item) => normalizeReviewItem(item, useEmoji)).filter(Boolean); - return String(content || '') - .split(/\n+/) - .map((line) => line.replace(/^\s*(\d+[.、)]|[-*])\s*/, '')) - .filter((line) => line.trim().length >= 30) - .map((line) => normalizeReviewItem(line, useEmoji)) - .filter(Boolean); -} - -function dedupeReviews(reviews) { - const seen = new Set(); - return reviews.filter((review) => { - const key = review.reviewContent.replace(/[,。!?,.!?\s]/g, '').slice(0, 80); - if (!key || seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function diversifyOpenings(reviews) { - const counts = new Map(); - const replacements = { - '说实话': ['其实,', '老实讲,', '用下来,'], - '本来': ['一开始', '之前', '原先'], - '我是': ['家里', '最近', '买回来后'], - }; - return reviews.map((review) => { - const opening = Object.keys(replacements).find((item) => review.reviewContent.startsWith(item)); - if (!opening) return review; - const used = counts.get(opening) || 0; - counts.set(opening, used + 1); - if (used === 0 && opening !== '我是') return review; - return { - ...review, - reviewContent: review.reviewContent.replace( - new RegExp(`^${opening}[,,、\s]*`), - replacements[opening][used % replacements[opening].length] - ), - }; - }); -} - -async function generateReviewBatch({ count, batchIndex, scenes, userTypes, sellingPoints, analysis, useEmoji }) { - const colors = normalizeMultiInput(analysis?.furnitureColors || analysis?.colors); - const prompt = [ - '生成拼多多家具真实买家评价。', - `1. 本批生成 ${count} 条。`, - '2. 每条 50-200 个中文字符。', - '3. 真实买家口吻,不要广告腔,不要出现“攻心”“卖点”“AI”。', - '4. 角度要有差异,覆盖使用前顾虑、场景、到手体验、细节、家人反馈。', - '5. 不要夸张承诺,不要医疗功效,不要绝对化表达。', - '6. 场景和使用人自然交叉组合。', - `7. 家具颜色只能使用这些图片识别颜色:${colors.length ? colors.join('、') : '未识别到颜色时不要主动描写颜色'}。`, - '8. 文风口语化,像真实用户随手写的评价,不要整齐的产品说明书语气。', - useEmoji - ? '9. 允许少量使用 emoji,每条最多 0-2 个,不要每条都必须有。' - : '9. 严禁使用任何 emoji、颜文字或表情符号,一个都不要出现。', - '10. 只返回 JSON 数组,每个元素字段固定:reviewContent、userType、sellingPoint、scene。', - '11. 同一批开头必须分散,尽量不要使用“我是、本来、说实话、一开始、之前、之前还担心”开头。', - `批次:${batchIndex}`, - `可用场景:${scenes.length ? scenes.join('、') : analysis?.visualScene || '按图片判断'}`, - `可用使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`, - `运营填写卖点:${sellingPoints}`, - `图片识别信息:${JSON.stringify(analysis || {})}`, - ].join('\n'); - const result = await callChatCompletion([{ role: 'user', content: prompt }], 'review', { - maxTokens: Math.min(4096, 240 * count), - }); - return { reviews: parseReviews(result.content, useEmoji), prompt, model: result.model }; -} - -async function runWithLimit(tasks, limit, onResult) { - let cursor = 0; - async function worker() { - while (cursor < tasks.length) { - const index = cursor; - cursor += 1; - const result = await tasks[index](); - onResult(result, index); - } - } - await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, () => worker())); -} - -async function generateHeartReviews({ - imageBuffer, mimeType, scene, userType, sellingPoints, useEmoji, reviewTotal, onEvent, -}) { - const startedAt = Date.now(); - const scenes = normalizeMultiInput(scene).filter((item) => SUPPORTED_SCENES.includes(item)); - const userTypes = normalizeMultiInput(userType).filter((item) => SUPPORTED_USER_TYPES.includes(item)); - const effectiveUserTypes = userTypes.length ? userTypes : ['自己购买给自己使用']; - const normalizedSellingPoints = String(sellingPoints || '').trim(); - const targetTotal = REVIEW_TOTAL_OPTIONS.includes(Number(reviewTotal)) - ? Number(reviewTotal) - : DEFAULT_REVIEW_TOTAL; - const normalizedUseEmoji = normalizeBoolean(useEmoji, true); - if (!normalizedSellingPoints) { - const error = new Error('请填写商品卖点'); - error.statusCode = 400; - throw error; - } - - const vision = await analyzeImage({ - imageBuffer, mimeType, scenes, userTypes: effectiveUserTypes, sellingPoints: normalizedSellingPoints, - }); - onEvent?.({ type: 'analysis', imageAnalysis: vision.analysis }); - - let reviews = []; - let reviewPrompt = ''; - let reviewModel = ''; - const append = (batch) => { - if (!reviewPrompt) reviewPrompt = batch.prompt; - if (!reviewModel) reviewModel = batch.model; - reviews = dedupeReviews(diversifyOpenings([...reviews, ...batch.reviews])).slice(0, targetTotal); - onEvent?.({ type: 'batch', total: reviews.length, targetTotal }); - }; - const makeTasks = (count, prefix) => Array.from({ length: Math.ceil(count / REVIEW_BATCH_SIZE) }, (_, index) => { - const batchCount = Math.min(REVIEW_BATCH_SIZE, count - index * REVIEW_BATCH_SIZE); - return () => generateReviewBatch({ - count: batchCount, - batchIndex: `${prefix}${index + 1}`, - scenes: scenes.length ? scenes : [vision.analysis?.visualScene].filter(Boolean), - userTypes: effectiveUserTypes, - sellingPoints: normalizedSellingPoints, - analysis: vision.analysis, - useEmoji: normalizedUseEmoji, - }); - }); - - await runWithLimit(makeTasks(targetTotal, ''), REVIEW_MAX_CONCURRENCY, append); - for (let attempt = 1; reviews.length < targetTotal && attempt <= REVIEW_MAX_REFILL_ATTEMPTS; attempt += 1) { - await runWithLimit(makeTasks(targetTotal - reviews.length, `补充${attempt}-`), REVIEW_MAX_CONCURRENCY, append); - } - if (reviews.length < targetTotal) { - const error = new Error(`AI返回评价数量不足,仅生成 ${reviews.length} 条,请重试`); - error.statusCode = 502; - throw error; - } - - return { - scene: scenes.length ? scenes.join('、') : (vision.analysis?.visualScene || 'AI识别场景'), - scenes, - userTypes: effectiveUserTypes, - sellingPoints: normalizedSellingPoints, - useEmoji: normalizedUseEmoji, - imageAnalysis: vision.analysis, - reviewPrompt, - reviews: reviews.slice(0, targetTotal), - total: targetTotal, - targetTotal, - responseMs: Date.now() - startedAt, - models: { vision: vision.model, review: reviewModel }, - }; -} - -function buildBuyerShowPrompt({ review, analysis, style = '真实' }) { - const normalizedStyle = style === '饱和' ? '饱和' : '真实'; - const productHints = normalizeMultiInput([ - analysis?.productCategory, - analysis?.productNameGuess, - ...(Array.isArray(analysis?.visualSellingPoints) ? analysis.visualSellingPoints : []), - ]).join('、'); - const styleRule = normalizedStyle === '饱和' - ? '画面可以更精致、饱满、明亮,接近小红书达人分享风格,但商品主体仍必须真实可信,不能变成棚拍硬广、样板间或3D渲染图。' - : '普通买家用手机随手拍的真实照片,生活气息强,略微随意,普通室内自然光,不要商业摄影感、样板间、卖场图或渲染图。'; - return [ - `根据上传的商品参考图和下面这条买家评价,生成${normalizedStyle}风格的买家秀图片。系统会分 5 次生成 5 张单图,每次只生成其中一张照片。`, - `当前风格:${normalizedStyle}`, - '硬性要求:必须以参考图里的商品为唯一主体,严格保持商品外观、颜色、材质、比例、结构、边角、五金、纹理、开孔、抽屉、柜门、支脚、轮子等全部产品细节不变。不能改款、变形、新增或删减部件。商品参考图仅用于锁定商品本体,不允许沿用其房间、背景、软装和场景。', - '家具结构数量限制:柜体、柜门、抽屉、层板、支脚、把手、开孔、轮子、拉篮和五金件数量、位置、比例必须与参考图一致。', - '组图要求:5 张结果图必须像同一位买家在同一个房间、同一个时间围绕同一个商品连续拍摄。整体规划为 2 张完整商品照片 + 3 张局部细节照片。禁止拼图、分屏、九宫格和多画面排版。', - `买家评价:${review.reviewContent || ''}`, - `使用人:${review.userType || '按评价自然判断'}`, - `使用场景:${review.scene || '按评价自然判断'}`, - `突出卖点:${review.sellingPoint || '按评价自然提炼'}`, - productHints ? `图片识别到的商品信息:${productHints}` : '', - `${styleRule} 如果上传了场景参考图,必须以它作为唯一主要场景依据,尽量保留房间类型、空间布局、墙面地面、窗户、非主体家具、软装、生活物品、光线氛围和模特姿态;只有冲突或遮挡商品主体的元素才可轻微调整。没有场景参考图时,才根据评价重新构建生活化场景。场景适合时最好需要有中国模特自然出镜,不能露脸,不能遮挡商品。输出 4:3,1K。`, - ].filter(Boolean).join('\n'); -} - -module.exports = { - DEFAULT_REVIEW_TOTAL, - REVIEW_TOTAL_OPTIONS, - SUPPORTED_SCENES, - SUPPORTED_USER_TYPES, - buildBuyerShowPrompt, - generateHeartReviews, -}; diff --git a/backend/src/services/aiBuyerShowSeedPackService.js b/backend/src/services/aiBuyerShowSeedPackService.js deleted file mode 100644 index 805a572..0000000 --- a/backend/src/services/aiBuyerShowSeedPackService.js +++ /dev/null @@ -1,237 +0,0 @@ -const crypto = require('crypto'); -const { - getPool, - query, - resolveUser, -} = require('./aiBuyerShowService'); -const { uploadBufferToDefaultS3 } = require('./objectStorage'); - -const SEED_PACK_TABLE = 'pdd_seed_resource_packs'; -const MAX_SEED_PACK_FILE_BYTES = 200 * 1024 * 1024; - -let tableReady = false; - -function normalizeString(value) { - return String(value || '').trim(); -} - -function normalizeNullableString(value) { - const text = normalizeString(value); - return text || null; -} - -// multer/busboy may decode multipart filenames as latin1, garbling non-ASCII -// names; re-encode to recover the original UTF-8 text. -function normalizeUploadFileName(value) { - const text = normalizeString(value); - if (!text) return ''; - try { - const decoded = Buffer.from(text, 'latin1').toString('utf8'); - return /[\uFFFD]/.test(decoded) ? text : decoded; - } catch (_) { - return text; - } -} - -async function ensureSeedPackTable() { - if (tableReady) return; - await query( - `CREATE TABLE IF NOT EXISTS ${SEED_PACK_TABLE} ( - id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资源包ID', - pack_uid VARCHAR(64) NOT NULL COMMENT '资源包唯一标识', - shop_name VARCHAR(128) NOT NULL COMMENT '店铺名称', - product_id VARCHAR(64) NOT NULL COMMENT '商品ID', - sku_name VARCHAR(255) NULL COMMENT 'SKU名称', - file_name VARCHAR(255) NOT NULL COMMENT 'ZIP文件名', - file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)', - object_key VARCHAR(512) NOT NULL COMMENT '对象存储Key', - file_url VARCHAR(1024) NULL COMMENT '公开下载地址', - review_count INT NOT NULL DEFAULT 0 COMMENT '包含评价条数', - operator_id VARCHAR(64) NULL COMMENT '上传人ID', - operator_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '上传人', - download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - UNIQUE KEY uk_pack_uid (pack_uid), - KEY idx_seed_pack_shop (shop_name), - KEY idx_seed_pack_product (product_id), - KEY idx_seed_pack_created (created_at) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='种菜资源包'` - ); - tableReady = true; -} - -function normalizePackRow(row = {}) { - return { - id: Number(row.id), - packUid: row.pack_uid ?? row.packUid ?? '', - shopName: row.shop_name ?? row.shopName ?? '', - productId: row.product_id ?? row.productId ?? '', - skuName: row.sku_name ?? row.skuName ?? '', - fileName: row.file_name ?? row.fileName ?? '', - fileSize: Number(row.file_size ?? row.fileSize ?? 0), - fileUrl: row.file_url ?? row.fileUrl ?? '', - reviewCount: Number(row.review_count ?? row.reviewCount ?? 0), - operatorName: row.operator_name ?? row.operatorName ?? '', - downloadCount: Number(row.download_count ?? row.downloadCount ?? 0), - createdAt: row.created_at || null, - }; -} - -function validateSeedPackInput({ shopName, productId, file }) { - if (!normalizeString(shopName)) { - const error = new Error('店铺名称不能为空'); - error.statusCode = 400; - return error; - } - if (!normalizeString(productId)) { - const error = new Error('商品ID不能为空'); - error.statusCode = 400; - return error; - } - if (!file?.buffer || !file.buffer.length) { - const error = new Error('ZIP 文件不能为空'); - error.statusCode = 400; - return error; - } - if (file.buffer.length > MAX_SEED_PACK_FILE_BYTES) { - const error = new Error('ZIP 文件不能超过 200MB'); - error.statusCode = 413; - return error; - } - return null; -} - -async function createSeedPack({ shopName, productId, skuName, fileName, reviewCount, file }, user = {}) { - await ensureSeedPackTable(); - const operator = resolveUser(user); - const validationError = validateSeedPackInput({ shopName, productId, file }); - if (validationError) throw validationError; - - const uploaded = await uploadBufferToDefaultS3( - file.buffer, - 'application/zip', - 'seed-packs', - 'zip' - ); - - const resolvedFileName = normalizeString(fileName) - || normalizeUploadFileName(file?.originalname) - || `种菜资源包_${Date.now()}.zip`; - - const packUid = crypto.randomUUID(); - const insertRows = await query( - `INSERT INTO ${SEED_PACK_TABLE} ( - pack_uid, shop_name, product_id, sku_name, file_name, file_size, - object_key, file_url, review_count, operator_id, operator_name - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - packUid, - normalizeString(shopName).slice(0, 128), - normalizeString(productId).slice(0, 64), - normalizeNullableString(String(skuName || '').slice(0, 255)), - resolvedFileName.slice(0, 255), - file.buffer.length, - uploaded.key, - uploaded.url || '', - Math.max(Number(reviewCount) || 0, 0), - operator.id || null, - operator.name, - ] - ); - - const rows = await query( - `SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`, - [insertRows.insertId] - ); - return normalizePackRow(rows[0]); -} - -async function listSeedPacks({ page = 1, pageSize = 15, keyword = '' } = {}) { - await ensureSeedPackTable(); - const normalizedPage = Math.max(Number(page) || 1, 1); - const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 15, 1), 100); - const offset = (normalizedPage - 1) * normalizedPageSize; - const normalizedKeyword = normalizeString(keyword); - const likeKeyword = `%${normalizedKeyword}%`; - const whereSql = normalizedKeyword - ? 'WHERE shop_name LIKE ? OR product_id LIKE ? OR sku_name LIKE ? OR operator_name LIKE ? OR file_name LIKE ?' - : ''; - const params = normalizedKeyword ? Array(5).fill(likeKeyword) : []; - - const countRows = await query( - `SELECT COUNT(*) AS total FROM ${SEED_PACK_TABLE} ${whereSql}`, - params - ); - const rows = await query( - `SELECT * FROM ${SEED_PACK_TABLE} - ${whereSql} - ORDER BY id DESC - LIMIT ? OFFSET ?`, - [...params, normalizedPageSize, offset] - ); - - return { - rows: rows.map(normalizePackRow), - total: Number(countRows[0]?.total || 0), - page: normalizedPage, - pageSize: normalizedPageSize, - }; -} - -async function getSeedPackDownload(id) { - await ensureSeedPackTable(); - const packId = Number(id); - if (!Number.isFinite(packId) || packId <= 0) { - const error = new Error('资源包不存在'); - error.statusCode = 404; - throw error; - } - const rows = await query( - `SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`, - [packId] - ); - const pack = rows[0]; - if (!pack) { - const error = new Error('资源包不存在'); - error.statusCode = 404; - throw error; - } - await query( - `UPDATE ${SEED_PACK_TABLE} SET download_count = download_count + 1 WHERE id = ?`, - [packId] - ); - return { - ...normalizePackRow(pack), - downloadCount: Number(pack.downloadCount || 0) + 1, - }; -} - -async function deleteSeedPack(id) { - await ensureSeedPackTable(); - const packId = Number(id); - if (!Number.isFinite(packId) || packId <= 0) { - const error = new Error('资源包不存在'); - error.statusCode = 404; - throw error; - } - const rows = await query( - `SELECT id FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`, - [packId] - ); - if (!rows.length) { - const error = new Error('资源包不存在'); - error.statusCode = 404; - throw error; - } - await query(`DELETE FROM ${SEED_PACK_TABLE} WHERE id = ?`, [packId]); - return { id: packId }; -} - -module.exports = { - createSeedPack, - deleteSeedPack, - getSeedPackDownload, - listSeedPacks, - MAX_SEED_PACK_FILE_BYTES, -}; diff --git a/backend/src/services/aiBuyerShowService.js b/backend/src/services/aiBuyerShowService.js deleted file mode 100644 index 29d0ee7..0000000 --- a/backend/src/services/aiBuyerShowService.js +++ /dev/null @@ -1,1132 +0,0 @@ -const crypto = require('crypto'); -const mysql = require('mysql2/promise'); -const database = require('../config/database'); -const { uploadBufferToDefaultS3 } = require('./objectStorage'); -const { - buildBuyerShowPrompt, - generateHeartReviews, -} = require('./aiBuyerShowReviewService'); - -const AI_BUYER_SHOW_DATABASE_CONFIG_NAME = 'ai_buyer_show_database'; -const GENERATION_TABLE = 'pdd_heart_review_generations'; -const REVIEW_TABLE = 'pdd_heart_review_items'; -const BUYER_SHOW_JOB_TABLE = 'pdd_buyer_show_jobs'; -const SOURCE_KEY = 'maixiang_ai_buyer_show'; -const ROUTE_PLAN_VERSION = 'v2-mixed-family'; - -const REQUIRED_TABLES = [GENERATION_TABLE, REVIEW_TABLE, BUYER_SHOW_JOB_TABLE]; -const IMAGE_EXTENSION_MAP = { - 'image/jpeg': 'jpg', - 'image/jpg': 'jpg', - 'image/png': 'png', - 'image/webp': 'webp', - 'image/gif': 'gif', -}; - -let poolCache = null; -let poolSignature = ''; - -function jsonStringify(value) { - if (value === undefined) return null; - try { - return JSON.stringify(value); - } catch (_) { - return null; - } -} - -function parseJson(value, fallback) { - if (value === undefined || value === null || value === '') return fallback; - if (typeof value !== 'string') return value; - try { - return JSON.parse(value); - } catch (_) { - return fallback; - } -} - -function normalizeString(value) { - return String(value || '').trim(); -} - -function normalizeNullableString(value) { - const text = normalizeString(value); - return text || null; -} - -function resolveImageExtension(file = {}) { - const byName = String(file.originalname || '').match(/\.([a-zA-Z0-9]+)$/)?.[1]; - if (byName) return byName.toLowerCase(); - return IMAGE_EXTENSION_MAP[file.mimetype] || 'jpg'; -} - -function resolveUser(user = {}) { - return { - id: normalizeString(user.sub || user.id || user.userId), - name: normalizeString(user.name || user.username || user.email) || 'maixiang', - }; -} - -function normalizeBuyerShowStyle(value) { - const text = normalizeString(value); - return ['真实', '饱和'].includes(text) ? text : '真实'; -} - -function buildRoutePlan() { - const first = Math.random() < 0.5 ? 'gpt-image-2' : 'nano-banana'; - const second = first === 'gpt-image-2' ? 'nano-banana' : 'gpt-image-2'; - return { - version: ROUTE_PLAN_VERSION, - source: SOURCE_KEY, - familyOrder: [first, second], - }; -} - -function getEnvDatabaseConfig() { - const host = process.env.AI_BUYER_SHOW_DB_HOST || process.env.NANTING_LOCAL_DB_HOST || ''; - const databaseName = process.env.AI_BUYER_SHOW_DB_NAME || process.env.NANTING_LOCAL_DB_NAME || ''; - const username = process.env.AI_BUYER_SHOW_DB_USER || process.env.NANTING_LOCAL_DB_USER || ''; - const password = process.env.AI_BUYER_SHOW_DB_PASSWORD || process.env.NANTING_LOCAL_DB_PASSWORD || ''; - if (!host || !databaseName || !username) { - return null; - } - return { - name: AI_BUYER_SHOW_DATABASE_CONFIG_NAME, - driver: 'mysql', - host, - port: Number(process.env.AI_BUYER_SHOW_DB_PORT || process.env.NANTING_LOCAL_DB_PORT || 3306), - database: databaseName, - username, - password, - ssl: String(process.env.AI_BUYER_SHOW_DB_SSL || process.env.NANTING_LOCAL_DB_SSL || '').toLowerCase() === 'true', - source: 'env', - }; -} - -async function getStoredDatabaseConfig() { - const rows = await database.local.query( - `SELECT name, driver, host, port, database_name AS databaseName, username, password, \`ssl\` AS sslValue, status - FROM database_configs - WHERE name = ? - LIMIT 1`, - [AI_BUYER_SHOW_DATABASE_CONFIG_NAME] - ); - const row = rows[0]; - if (!row || row.status === 'inactive') { - return null; - } - return { - name: row.name, - driver: row.driver || 'mysql', - host: row.host, - port: Number(row.port || 3306), - database: row.databaseName, - username: row.username, - password: row.password || '', - ssl: row.sslValue === 1 || row.sslValue === true, - source: 'database_configs', - }; -} - -async function getAiBuyerShowDatabaseConfig() { - const stored = await getStoredDatabaseConfig(); - const config = stored || getEnvDatabaseConfig(); - if (!config) { - const error = new Error('AI买家秀数据库未配置,请在系统管理-数据库配置中新增 ai_buyer_show_database'); - error.statusCode = 503; - throw error; - } - if (String(config.driver || 'mysql').toLowerCase() !== 'mysql') { - const error = new Error('AI买家秀数据库必须配置为 MySQL'); - error.statusCode = 400; - throw error; - } - return config; -} - -function buildPoolSignature(config) { - return [config.host, config.port, config.database, config.username, config.password, config.ssl ? '1' : '0'].join('|'); -} - -async function getPool() { - const config = await getAiBuyerShowDatabaseConfig(); - const signature = buildPoolSignature(config); - if (poolCache && poolSignature === signature) { - return poolCache; - } - if (poolCache) { - await poolCache.end().catch(() => {}); - } - poolSignature = signature; - poolCache = mysql.createPool({ - host: config.host, - port: Number(config.port || 3306), - user: config.username, - password: config.password || '', - database: config.database, - charset: 'utf8mb4', - waitForConnections: true, - connectionLimit: 4, - queueLimit: 20, - connectTimeout: 10000, - enableKeepAlive: true, - keepAliveInitialDelay: 0, - ssl: config.ssl ? { rejectUnauthorized: true } : false, - }); - return poolCache; -} - -async function query(sql, params = []) { - const pool = await getPool(); - const [rows] = await pool.query({ sql, timeout: 60000 }, params); - return rows; -} - -async function testAiBuyerShowDatabase() { - const config = await getAiBuyerShowDatabaseConfig(); - const rows = await query( - `SELECT table_name AS tableName - FROM information_schema.tables - WHERE table_schema = DATABASE() - AND table_name IN (?, ?, ?)`, - REQUIRED_TABLES - ); - const existing = new Set(rows.map((row) => row.tableName)); - const missingTables = REQUIRED_TABLES.filter((table) => !existing.has(table)); - return { - ok: missingTables.length === 0, - config: { - name: config.name, - host: config.host, - port: config.port, - database: config.database, - username: config.username, - source: config.source, - }, - missingTables, - }; -} - -async function uploadReferenceFiles(files = [], folder) { - const uploaded = []; - for (const file of files) { - if (!file?.buffer) continue; - const result = await uploadBufferToDefaultS3( - file.buffer, - file.mimetype || 'image/jpeg', - folder, - resolveImageExtension(file) - ); - uploaded.push({ - url: result.url, - key: result.key, - name: file.originalname || '', - mimeType: file.mimetype || 'image/jpeg', - }); - } - return uploaded; -} - -function buildDefaultPrompt(payload = {}) { - const productName = normalizeString(payload.productName || payload.spu || '商品'); - const scene = normalizeString(payload.scene) || '真实生活场景'; - const userType = normalizeString(payload.userType) || '真实买家'; - const sellingPoints = normalizeString(payload.sellingPoints) || '商品核心卖点'; - const reviewText = normalizeString(payload.reviewText) || `${userType}在${scene}中使用${productName},整体体验自然真实。`; - return [ - '根据上传的商品参考图和下面这条买家使用描述,生成买家秀风格的手机实拍图。系统会分 5 次生成 5 张单图,每次只生成其中一张照片。', - '硬性要求:必须以商品参考图里的商品为唯一主体,严格保持商品外观、颜色、材质、比例、结构、边角、五金、纹理、开孔、抽屉、柜门、支脚、轮子等全部产品细节不变。不能改款,不能变形,不能新增或删减部件,不能把商品变成其他品类。', - '商品参考图只用于锁定商品本体,不允许沿用商品参考图里自带的房间、背景、软装、构图和场景氛围。', - '如果上传了人工场景参考图,必须优先参考场景参考图中的房间布局、墙地面、窗户、软装、非主体家具、生活物品、光线、模特姿态和出镜方式;场景参考图里的非主体元素尽量保留,只把目标商品自然放入其中,不能拿场景图改商品外观。', - '如果没有人工场景参考图,再根据买家使用描述重新构建生活化场景。', - '组图要求:5 张结果图必须像同一位买家在同一个房间、同一个时间、围绕同一个商品连续拍摄。整体规划为 2 张完整商品照片 + 3 张局部细节照片。', - `商品名称/SPU:${productName}`, - `买家使用描述:${reviewText}`, - `使用人:${userType}`, - `使用场景:${scene}`, - `突出卖点:${sellingPoints}`, - '画面要求:普通买家用手机随手拍的真实照片,生活气息强,略微随意,普通室内自然光。画面尽可能饱满,带一点小红书达人分享式的生活感和精致感,但本质仍然是真实买家秀。', - '人物要求:最好需要有模特出镜,模特必须是中国人,不能正脸出镜,优先背影、侧身、局部肢体或自然使用中的状态,不能遮挡商品主体。', - '禁止拼图、分屏、九宫格、照片墙、详情页排版、文字贴片或广告海报式画面。', - ].join('\n'); -} - -function normalizeImageRecord(image) { - if (typeof image === 'string') { - const url = normalizeString(image); - return url ? { originalUrl: url, thumbnailUrl: url } : null; - } - if (!image || typeof image !== 'object') return null; - const originalUrl = normalizeString( - image.originalUrl || image.imageUrl || image.url || image.thumbnailUrl - ); - if (!originalUrl) return null; - return { - ...image, - originalUrl, - thumbnailUrl: normalizeString(image.thumbnailUrl) || originalUrl, - }; -} - -function normalizeImages(value) { - const parsed = parseJson(value, []); - const list = Array.isArray(parsed) ? parsed : []; - return list.map(normalizeImageRecord).filter(Boolean); -} - -function mergeImages(...groups) { - const seen = new Set(); - const merged = []; - groups.flat().forEach((image) => { - const normalized = normalizeImageRecord(image); - if (!normalized || seen.has(normalized.originalUrl)) return; - seen.add(normalized.originalUrl); - merged.push(normalized); - }); - return merged; -} - -function normalizeJobRow(row = {}) { - const result = parseJson(row.resultJson, null); - const checkpoint = result?.checkpoint || null; - const resultImages = Array.isArray(result?.images) - ? result.images - : Array.isArray(checkpoint?.images) - ? checkpoint.images - : []; - const images = mergeImages( - normalizeImages(row.buyerShowImagesJson), - normalizeImages(row.buyerShowImageUrlsJson), - row.buyerShowImageUrl ? [row.buyerShowImageUrl] : [], - resultImages - ); - const imageUrls = images.map((image) => image.originalUrl).filter(Boolean); - return { - jobId: row.jobUid || `review-${row.reviewRecordId}`, - status: row.buyerShowStatus || row.status, - progress: Number(row.progress || 0), - error: row.buyerShowError || row.errorMessage || '', - generationId: Number(row.generationId || 0), - reviewRecordId: Number(row.reviewRecordId || 0), - reviewIndex: Math.max(Number(row.reviewOrder || 1) - 1, 0), - imageCount: Number(row.imageCount || 5), - productName: row.spu || row.productName || '', - scene: row.scene || '', - userType: row.userType || '', - sellingPoints: row.sellingPoint || row.sellingPoints || '', - reviewText: row.reviewContent || '', - buyerShowStyle: row.buyerShowStyle || '真实', - prompt: row.prompt || '', - productImageUrls: parseJson(row.originalImageUrlsJson, row.originalImageUrl ? [row.originalImageUrl] : []), - sceneReferenceImages: parseJson(row.sceneReferenceImagesJson, []), - result, - images, - imageUrls, - operatorName: row.operatorName || '', - createdAt: row.createdAt, - updatedAt: row.updatedAt, - completedAt: row.buyerShowUpdatedAt || row.completedAt, - }; -} - -async function getQueueStats(jobUid = '') { - const totalRows = await query( - `SELECT - COUNT(*) AS total, - SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pendingTotal, - SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS runningTotal - FROM ${BUYER_SHOW_JOB_TABLE} - WHERE status IN ('pending', 'running')` - ); - let queueAhead = 0; - const normalizedJobUid = normalizeString(jobUid); - if (normalizedJobUid) { - const rows = await query(`SELECT id FROM ${BUYER_SHOW_JOB_TABLE} WHERE job_uid = ? LIMIT 1`, [normalizedJobUid]); - const jobId = Number(rows[0]?.id || 0); - if (jobId > 0) { - const aheadRows = await query( - `SELECT COUNT(*) AS total - FROM ${BUYER_SHOW_JOB_TABLE} - WHERE status IN ('pending', 'running') AND id < ?`, - [jobId] - ); - queueAhead = Number(aheadRows[0]?.total || 0); - } - } - return { - queueAhead, - queueTotal: Number(totalRows[0]?.total || 0), - pendingTotal: Number(totalRows[0]?.pendingTotal || 0), - runningTotal: Number(totalRows[0]?.runningTotal || 0), - }; -} - -/** - * The image worker is deployed separately from the API process. There is no - * shared process registry, so expose the worker state observed from the job - * table instead of making the desktop client guess from missing fields. - */ -async function getWorkerRuntimeStatus(queue = {}) { - const [activityRows, failureRows] = await Promise.all([ - query( - `SELECT status, updated_at AS updatedAt - FROM ${BUYER_SHOW_JOB_TABLE} - ORDER BY updated_at DESC, id DESC - LIMIT 1` - ), - query( - `SELECT error_message AS errorMessage, updated_at AS updatedAt - FROM ${BUYER_SHOW_JOB_TABLE} - WHERE status = 'failed' AND error_message IS NOT NULL AND error_message <> '' - ORDER BY updated_at DESC, id DESC - LIMIT 1` - ), - ]); - const latest = activityRows[0] || null; - const latestFailure = failureRows[0] || null; - const hasQueue = Number(queue.queueTotal || 0) >= 0; - const status = !hasQueue - ? 'unknown' - : Number(queue.runningTotal || 0) > 0 || Number(queue.pendingTotal || 0) > 0 - ? 'active' - : latestFailure && latestFailure.updatedAt && latest?.updatedAt - && new Date(latestFailure.updatedAt).getTime() >= new Date(latest.updatedAt).getTime() - ? 'idle_with_errors' - : 'idle'; - return { - // The worker is an external service. These flags describe the queue - // contract; an explicit false can still be supplied by a future worker - // health integration. - enabled: hasQueue, - // The key belongs to the separately deployed worker and is intentionally - // not exposed by this API. null means "unknown", not "missing". - hasApiKey: null, - status, - source: 'job-table-observation', - lastActivityAt: latest?.updatedAt || null, - lastError: latestFailure?.errorMessage || '', - lastErrorAt: latestFailure?.updatedAt || null, - }; -} - -async function generateAiBuyerShowReviews(payload = {}, user = {}) { - const productImageFiles = payload.productImageFiles || []; - if (!productImageFiles[0]?.buffer) { - const error = new Error('请先上传商品参考图'); - error.statusCode = 400; - throw error; - } - - const result = await generateHeartReviews({ - imageBuffer: productImageFiles[0].buffer, - mimeType: productImageFiles[0].mimetype, - scene: payload.scene, - userType: payload.userType, - sellingPoints: payload.sellingPoints, - useEmoji: payload.useEmoji, - reviewTotal: payload.reviewTotal, - }); - const uploadedImages = await uploadReferenceFiles( - productImageFiles, - 'ai-buyer-show-product-reference' - ); - const productImageUrls = [ - ...uploadedImages.map((item) => item.url), - ...((payload.productImageUrls || []).map(normalizeString).filter(Boolean)), - ]; - const operator = resolveUser(user); - const generationUid = crypto.randomUUID(); - const productName = normalizeString(payload.productName || payload.spu); - const metadata = { - ...(result.models || {}), - source: SOURCE_KEY, - productImageUrls, - createdBy: operator, - }; - - const pool = await getPool(); - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - const [generationInsert] = await connection.query( - `INSERT INTO ${GENERATION_TABLE} ( - generation_uid, spu, scene, scenes_json, user_types_json, selling_points, - use_emoji, original_image_data_url, original_image_url, original_image_urls_json, - original_image_mime_type, review_total, target_total, image_analysis_json, - review_prompt, ai_models_json, response_ms, operator_id, operator_name, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'completed')`, - [ - generationUid, - normalizeNullableString(productName), - normalizeNullableString(result.scene), - jsonStringify(result.scenes || []), - jsonStringify(result.userTypes || []), - normalizeNullableString(result.sellingPoints), - result.useEmoji === false ? 0 : 1, - productImageUrls[0], - jsonStringify(productImageUrls), - productImageFiles[0].mimetype || 'image/jpeg', - Number(result.total || 0), - Number(result.targetTotal || result.total || 0), - jsonStringify(result.imageAnalysis || {}), - result.reviewPrompt || null, - jsonStringify(metadata), - Number(result.responseMs || 0), - operator.id || null, - operator.name, - ] - ); - const generationId = Number(generationInsert.insertId); - const savedReviews = []; - for (let index = 0; index < result.reviews.length; index += 1) { - const review = result.reviews[index] || {}; - const [reviewInsert] = await connection.query( - `INSERT INTO ${REVIEW_TABLE} ( - generation_id, review_order, review_content, user_type, selling_point, scene - ) VALUES (?, ?, ?, ?, ?, ?)`, - [ - generationId, - index + 1, - normalizeString(review.reviewContent), - normalizeNullableString(review.userType), - normalizeNullableString(review.sellingPoint), - normalizeNullableString(review.scene), - ] - ); - savedReviews.push({ - ...review, - generationId, - generationUid, - reviewRecordId: Number(reviewInsert.insertId), - reviewOrder: index + 1, - }); - } - await connection.commit(); - return { - ...result, - generationId, - generationUid, - productName, - spu: productName, - productImageUrls, - reviews: savedReviews, - }; - } catch (error) { - await connection.rollback().catch(() => {}); - throw error; - } finally { - connection.release(); - } -} - -async function createAiBuyerShowJob(payload = {}, user = {}) { - const productImageFiles = payload.productImageFiles || []; - const sceneReferenceImageFiles = payload.sceneReferenceImageFiles || []; - const productImageUrls = [ - ...((payload.productImageUrls || []).map(normalizeString).filter(Boolean)), - ...(await uploadReferenceFiles(productImageFiles, 'ai-buyer-show-product-reference')).map((item) => item.url), - ]; - const sceneReferenceImagePayloads = [ - ...((payload.sceneReferenceImageUrls || []).map(normalizeString).filter(Boolean)).map((url, index) => ({ - name: `scene-reference-url-${index + 1}.jpg`, - mimeType: 'image/jpeg', - url, - })), - ...(await uploadReferenceFiles(sceneReferenceImageFiles, 'ai-buyer-show-scene-reference')).map((item) => ({ - name: item.name || 'scene-reference.jpg', - mimeType: item.mimeType || 'image/jpeg', - url: item.url, - })), - ]; - - if (!productImageUrls.length) { - const error = new Error('请先上传商品参考图'); - error.statusCode = 400; - throw error; - } - - const operator = resolveUser(user); - const jobUid = crypto.randomUUID(); - const imageCount = 5; - const buyerShowStyle = normalizeBuyerShowStyle(payload.buyerShowStyle); - const routePlan = buildRoutePlan(); - const detailImages = productImageUrls.slice(1).map((url, index) => ({ - name: `product-reference-${index + 2}.jpg`, - mimeType: 'image/jpeg', - url, - })); - const initialResult = { - routePlan, - checkpoint: { - images: [], - imageUrls: [], - requestIds: [], - generatedImageIds: [], - generatedAssetIds: [], - attempts: [], - model: null, - expectedImageCount: imageCount, - originalImagePersisted: 0, - }, - }; - - const pool = await getPool(); - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - const requestedGenerationId = Number(payload.generationId || 0); - const requestedReviewRecordId = Number(payload.reviewRecordId || 0); - let generationId = 0; - let reviewRecordId = 0; - let reviewOrder = 1; - let productName = normalizeString(payload.productName || payload.spu) || 'AI买家秀商品'; - let reviewText = normalizeString(payload.reviewText); - let reviewScene = normalizeString(payload.scene); - let reviewUserType = normalizeString(payload.userType); - let reviewSellingPoint = normalizeString(payload.sellingPoints); - let imageAnalysis = null; - - if (requestedGenerationId > 0 && requestedReviewRecordId > 0) { - const [existingRows] = await connection.query( - `SELECT - g.id AS generationId, g.spu, g.image_analysis_json AS imageAnalysisJson, - r.id AS reviewRecordId, r.review_order AS reviewOrder, - r.review_content AS reviewContent, r.user_type AS userType, - r.selling_point AS sellingPoint, r.scene - FROM ${GENERATION_TABLE} g - INNER JOIN ${REVIEW_TABLE} r ON r.generation_id = g.id - WHERE g.id = ? AND r.id = ? - LIMIT 1`, - [requestedGenerationId, requestedReviewRecordId] - ); - const existing = existingRows[0]; - if (!existing) { - const error = new Error('所选评价不存在或已被删除'); - error.statusCode = 404; - throw error; - } - generationId = Number(existing.generationId); - reviewRecordId = Number(existing.reviewRecordId); - reviewOrder = Number(existing.reviewOrder || 1); - productName = existing.spu || productName; - reviewText = existing.reviewContent || reviewText; - reviewScene = existing.scene || reviewScene; - reviewUserType = existing.userType || reviewUserType; - reviewSellingPoint = existing.sellingPoint || reviewSellingPoint; - imageAnalysis = parseJson(existing.imageAnalysisJson, {}); - } - - const prompt = normalizeString(payload.prompt) || (reviewRecordId > 0 - ? buildBuyerShowPrompt({ - review: { - reviewContent: reviewText, - userType: reviewUserType, - sellingPoint: reviewSellingPoint, - scene: reviewScene, - }, - analysis: imageAnalysis, - style: buyerShowStyle, - }) - : buildDefaultPrompt({ - ...payload, - productName, - reviewText, - scene: reviewScene, - userType: reviewUserType, - sellingPoints: reviewSellingPoint, - })); - - if (!generationId) { - const generationUid = crypto.randomUUID(); - const metadata = { - source: SOURCE_KEY, - productName, - productImageUrls, - sceneReferenceImageUrls: sceneReferenceImagePayloads.map((item) => item.url).filter(Boolean), - createdBy: operator, - }; - const [generationResult] = await connection.query( - `INSERT INTO ${GENERATION_TABLE} ( - generation_uid, - spu, - scene, - scenes_json, - user_types_json, - selling_points, - use_emoji, - original_image_data_url, - original_image_url, - original_image_urls_json, - original_image_mime_type, - review_total, - target_total, - image_analysis_json, - review_prompt, - ai_models_json, - response_ms, - operator_id, - operator_name, - status - ) VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, ?, ?, ?, ?, NULL, ?, ?, NULL, ?, ?, 'completed')`, - [ - generationUid, - normalizeNullableString(productName), - normalizeNullableString(payload.scene), - jsonStringify(normalizeString(payload.scene) ? [normalizeString(payload.scene)] : []), - jsonStringify(normalizeString(payload.userType) ? [normalizeString(payload.userType)] : []), - normalizeNullableString(payload.sellingPoints), - productImageUrls[0], - jsonStringify(productImageUrls), - productImageFiles[0]?.mimetype || 'image/jpeg', - 1, - 1, - prompt, - jsonStringify(metadata), - operator.id, - operator.name, - ] - ); - generationId = Number(generationResult.insertId); - const [reviewResult] = await connection.query( - `INSERT INTO ${REVIEW_TABLE} ( - generation_id, - review_order, - review_content, - user_type, - selling_point, - scene, - buyer_show_prompt, - buyer_show_style, - buyer_show_status, - buyer_show_updated_at - ) VALUES (?, 1, ?, ?, ?, ?, ?, ?, 'running', NOW())`, - [ - generationId, - reviewText, - normalizeNullableString(reviewUserType), - normalizeNullableString(reviewSellingPoint), - normalizeNullableString(reviewScene), - prompt, - buyerShowStyle, - ] - ); - reviewRecordId = Number(reviewResult.insertId); - } else { - await connection.query( - `UPDATE ${REVIEW_TABLE} - SET buyer_show_prompt = ?, buyer_show_style = ?, buyer_show_status = 'running', - buyer_show_error = NULL, buyer_show_updated_at = NOW() - WHERE id = ? AND generation_id = ?`, - [prompt, buyerShowStyle, reviewRecordId, generationId] - ); - } - const [queueRows] = await connection.query( - `SELECT COUNT(*) AS total FROM ${BUYER_SHOW_JOB_TABLE} WHERE status IN ('pending', 'running')` - ); - const queueAhead = Number(queueRows[0]?.total || 0); - await connection.query( - `INSERT INTO ${BUYER_SHOW_JOB_TABLE} ( - job_uid, - generation_id, - review_record_id, - review_order, - prompt, - negative_prompt, - buyer_show_style, - image_count, - aspect_ratio, - image_size, - original_image_data_url, - original_image_file_path, - original_image_url, - original_image_mime_type, - detail_images_json, - scene_reference_images_json, - result_json, - status, - progress - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', NULL, ?, ?, ?, ?, ?, 'pending', 0)`, - [ - jobUid, - generationId, - reviewRecordId, - reviewOrder, - prompt, - normalizeNullableString(payload.negativePrompt), - buyerShowStyle, - imageCount, - normalizeString(payload.aspectRatio) || '4:3', - normalizeString(payload.imageSize) || '1K', - productImageUrls[0], - productImageFiles[0]?.mimetype || 'image/jpeg', - jsonStringify(detailImages), - jsonStringify(sceneReferenceImagePayloads), - jsonStringify(initialResult), - ] - ); - await connection.commit(); - return { - jobId: jobUid, - status: 'pending', - progress: 0, - queueAhead, - generationId, - reviewRecordId, - reviewIndex: Math.max(reviewOrder - 1, 0), - productImageUrls, - sceneReferenceImages: sceneReferenceImagePayloads, - prompt, - buyerShowStyle, - }; - } catch (error) { - await connection.rollback().catch(() => {}); - throw error; - } finally { - connection.release(); - } -} - -async function getAiBuyerShowJob(jobUid) { - const rows = await query( - `SELECT - j.job_uid AS jobUid, - j.generation_id AS generationId, - j.review_record_id AS reviewRecordId, - j.review_order AS reviewOrder, - j.prompt, - j.buyer_show_style AS buyerShowStyle, - j.image_count AS imageCount, - j.original_image_url AS originalImageUrl, - j.detail_images_json AS detailImagesJson, - j.scene_reference_images_json AS sceneReferenceImagesJson, - j.status, - j.progress, - j.result_json AS resultJson, - j.error_message AS errorMessage, - j.completed_at AS completedAt, - j.created_at AS createdAt, - j.updated_at AS updatedAt, - g.spu, - g.scene, - g.original_image_urls_json AS originalImageUrlsJson, - r.review_content AS reviewContent, - r.user_type AS userType, - r.selling_point AS sellingPoint, - r.buyer_show_image_url AS buyerShowImageUrl, - r.buyer_show_image_urls_json AS buyerShowImageUrlsJson, - r.buyer_show_images_json AS buyerShowImagesJson, - r.buyer_show_status AS buyerShowStatus, - r.buyer_show_error AS buyerShowError, - r.buyer_show_updated_at AS buyerShowUpdatedAt, - g.operator_name AS operatorName - FROM ${BUYER_SHOW_JOB_TABLE} j - LEFT JOIN ${GENERATION_TABLE} g ON g.id = j.generation_id - LEFT JOIN ${REVIEW_TABLE} r ON r.id = j.review_record_id - WHERE j.job_uid = ? - LIMIT 1`, - [normalizeString(jobUid)] - ); - if (!rows[0]) return null; - return { - ...normalizeJobRow(rows[0]), - queue: await getQueueStats(jobUid), - }; -} - -async function listAiBuyerShowJobs({ - page = 1, - pageSize = 20, - spu = '', - includeTotal = true, -} = {}) { - const normalizedPage = Math.max(Number(page) || 1, 1); - const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 200, 1), 200); - const offset = (normalizedPage - 1) * normalizedPageSize; - const normalizedSpu = normalizeString(spu); - const whereSql = ` - WHERE r.buyer_show_status IN ('completed', 'partial') - AND ( - (r.buyer_show_image_url IS NOT NULL AND r.buyer_show_image_url <> '') - OR (r.buyer_show_image_urls_json IS NOT NULL AND r.buyer_show_image_urls_json NOT IN ('', '[]', 'null')) - OR (r.buyer_show_images_json IS NOT NULL AND r.buyer_show_images_json NOT IN ('', '[]', 'null')) - ) - ${normalizedSpu ? 'AND g.spu LIKE ?' : ''}`; - const params = normalizedSpu ? [`%${normalizedSpu}%`] : []; - const shouldIncludeTotal = includeTotal === true || String(includeTotal) === '1'; - const totalRows = shouldIncludeTotal - ? await query( - `SELECT COUNT(*) AS total - FROM ${REVIEW_TABLE} r - INNER JOIN ${GENERATION_TABLE} g ON g.id = r.generation_id - ${whereSql}`, - params - ) - : []; - const rows = await query( - `SELECT - r.generation_id AS generationId, - r.id AS reviewRecordId, - r.review_order AS reviewOrder, - r.buyer_show_style AS buyerShowStyle, - 5 AS imageCount, - 100 AS progress, - r.created_at AS createdAt, - r.updated_at AS updatedAt, - g.spu, - g.scene, - r.review_content AS reviewContent, - r.user_type AS userType, - r.selling_point AS sellingPoint, - r.buyer_show_image_url AS buyerShowImageUrl, - r.buyer_show_image_urls_json AS buyerShowImageUrlsJson, - r.buyer_show_images_json AS buyerShowImagesJson, - r.buyer_show_status AS buyerShowStatus, - r.buyer_show_error AS buyerShowError, - r.buyer_show_updated_at AS buyerShowUpdatedAt, - g.operator_name AS operatorName - FROM ${REVIEW_TABLE} r - INNER JOIN ${GENERATION_TABLE} g ON g.id = r.generation_id - ${whereSql} - ORDER BY COALESCE(r.buyer_show_updated_at, r.updated_at, r.created_at) DESC, r.id DESC - LIMIT ? OFFSET ?`, - [...params, normalizedPageSize + 1, offset] - ); - const hasExtraRow = rows.length > normalizedPageSize; - const pageRows = hasExtraRow ? rows.slice(0, normalizedPageSize) : rows; - const total = shouldIncludeTotal ? Number(totalRows[0]?.total || 0) : null; - return { - rows: pageRows.map(normalizeJobRow), - total, - page: normalizedPage, - pageSize: normalizedPageSize, - hasMore: shouldIncludeTotal ? offset + pageRows.length < total : hasExtraRow, - }; -} - -async function listAiBuyerShowHistory({ - page = 1, - pageSize = 15, - keyword = '', -} = {}) { - const normalizedPage = Math.max(Number(page) || 1, 1); - const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 15, 1), 100); - const offset = (normalizedPage - 1) * normalizedPageSize; - const normalizedKeyword = normalizeString(keyword); - const whereSql = normalizedKeyword - ? 'WHERE g.spu LIKE ? OR g.scene LIKE ? OR g.selling_points LIKE ? OR g.operator_name LIKE ?' - : ''; - const params = normalizedKeyword ? Array(4).fill(`%${normalizedKeyword}%`) : []; - const countRows = await query( - `SELECT COUNT(*) AS total FROM ${GENERATION_TABLE} g ${whereSql}`, - params - ); - const rows = await query( - `SELECT - g.id, - g.generation_uid AS generationUid, - g.spu, - g.scene, - g.selling_points AS sellingPoints, - g.use_emoji AS useEmoji, - g.review_total AS reviewTotal, - g.target_total AS targetTotal, - g.operator_name AS operatorName, - g.created_at AS createdAt, - g.original_image_url AS originalImageUrl, - g.original_image_urls_json AS originalImageUrlsJson, - COUNT(r.id) AS reviewCount, - SUM(CASE - WHEN r.buyer_show_image_url IS NOT NULL AND r.buyer_show_image_url <> '' THEN 1 - WHEN r.buyer_show_image_urls_json IS NOT NULL AND r.buyer_show_image_urls_json NOT IN ('', '[]', 'null') THEN 1 - WHEN r.buyer_show_images_json IS NOT NULL AND r.buyer_show_images_json NOT IN ('', '[]', 'null') THEN 1 - ELSE 0 - END) AS buyerShowCount - FROM ${GENERATION_TABLE} g - LEFT JOIN ${REVIEW_TABLE} r ON r.generation_id = g.id - ${whereSql} - GROUP BY g.id - ORDER BY g.id DESC - LIMIT ? OFFSET ?`, - [...params, normalizedPageSize, offset] - ); - - return { - rows: rows.map(({ originalImageUrl, originalImageUrlsJson, ...row }) => ({ - ...row, - productImageUrls: parseJson( - originalImageUrlsJson, - originalImageUrl ? [originalImageUrl] : [] - ).filter(Boolean), - reviewCount: Number(row.reviewCount || 0), - buyerShowCount: Number(row.buyerShowCount || 0), - useEmoji: Number(row.useEmoji) !== 0, - })), - total: Number(countRows[0]?.total || 0), - page: normalizedPage, - pageSize: normalizedPageSize, - }; -} - -async function getAiBuyerShowHistoryDetail(id) { - const generationId = Number(id); - if (!Number.isFinite(generationId) || generationId <= 0) return null; - - const generationRows = await query( - `SELECT - g.id AS generationId, - g.generation_uid AS generationUid, - g.spu, - g.scene, - g.scenes_json AS scenesJson, - g.user_types_json AS userTypesJson, - g.selling_points AS sellingPoints, - g.use_emoji AS useEmoji, - g.original_image_url AS originalImageUrl, - g.original_image_urls_json AS originalImageUrlsJson, - g.original_image_mime_type AS originalImageMimeType, - g.review_total AS total, - g.target_total AS targetTotal, - g.image_analysis_json AS imageAnalysisJson, - g.review_prompt AS reviewPrompt, - g.operator_id AS operatorId, - g.operator_name AS operatorName, - g.created_at AS createdAt - FROM ${GENERATION_TABLE} g - WHERE g.id = ? - LIMIT 1`, - [generationId] - ); - const generation = generationRows[0]; - if (!generation) return null; - - const reviewRows = await query( - `SELECT - r.id AS reviewRecordId, - r.generation_id AS generationId, - r.review_order AS reviewOrder, - r.review_content AS reviewContent, - r.user_type AS userType, - r.selling_point AS sellingPoint, - r.scene, - r.buyer_show_image_url AS buyerShowImageUrl, - r.buyer_show_image_urls_json AS buyerShowImageUrlsJson, - r.buyer_show_images_json AS buyerShowImagesJson, - r.buyer_show_prompt AS buyerShowPrompt, - r.buyer_show_style AS buyerShowStyle, - r.buyer_show_status AS buyerShowStatus, - r.buyer_show_error AS buyerShowError, - (SELECT j.status - FROM ${BUYER_SHOW_JOB_TABLE} j - WHERE j.review_record_id = r.id - ORDER BY j.id DESC - LIMIT 1) AS buyerShowJobStatus - FROM ${REVIEW_TABLE} r - WHERE r.generation_id = ? - ORDER BY r.review_order ASC`, - [generationId] - ); - const sceneRows = await query( - `SELECT j.review_record_id AS reviewRecordId, - j.review_order AS reviewOrder, - j.scene_reference_images_json AS sceneReferenceImagesJson - FROM ${BUYER_SHOW_JOB_TABLE} j - WHERE j.generation_id = ? - AND j.scene_reference_images_json IS NOT NULL - AND j.scene_reference_images_json <> '' - ORDER BY j.id DESC`, - [generationId] - ); - const sceneByRecordId = new Map(); - const sceneByReviewOrder = new Map(); - sceneRows.forEach((row) => { - const images = normalizeImages(row.sceneReferenceImagesJson); - const reviewRecordId = Number(row.reviewRecordId || 0); - const reviewOrder = Number(row.reviewOrder || 0); - if (images.length && reviewRecordId > 0 && !sceneByRecordId.has(reviewRecordId)) { - sceneByRecordId.set(reviewRecordId, images); - } - if (images.length && reviewOrder > 0 && !sceneByReviewOrder.has(reviewOrder)) { - sceneByReviewOrder.set(reviewOrder, images); - } - }); - - const reviews = reviewRows.map((row) => { - const sceneReferenceImages = sceneByRecordId.get(Number(row.reviewRecordId || 0)) - || sceneByReviewOrder.get(Number(row.reviewOrder || 0)) - || []; - const buyerShowJobStatus = normalizeString(row.buyerShowJobStatus).toLowerCase(); - const storedBuyerShowStatus = normalizeString(row.buyerShowStatus).toLowerCase(); - return { - generationId: Number(row.generationId), - generationUid: generation.generationUid, - reviewRecordId: Number(row.reviewRecordId), - reviewOrder: Number(row.reviewOrder), - reviewContent: row.reviewContent || '', - userType: row.userType || '', - sellingPoint: row.sellingPoint || '', - scene: row.scene || '', - buyerShowImageUrl: row.buyerShowImageUrl || '', - buyerShowImageUrls: parseJson(row.buyerShowImageUrlsJson, []), - buyerShowImages: normalizeImages(row.buyerShowImagesJson), - buyerShowPrompt: row.buyerShowPrompt || '', - buyerShowStyle: row.buyerShowStyle || '真实', - // A review is not waiting in the queue merely because it has never had - // a buyer-show job. Use the latest job status when one exists, and keep - // untouched reviews explicitly available for the next submission. - buyerShowStatus: buyerShowJobStatus || (storedBuyerShowStatus === 'pending' - ? 'not_started' - : storedBuyerShowStatus || 'not_started'), - buyerShowJobStatus, - buyerShowError: row.buyerShowError || '', - buyerShowSceneReferenceImages: sceneReferenceImages, - buyerShowSceneReferenceImageUrls: sceneReferenceImages.map((image) => image.originalUrl), - }; - }); - const originalImageUrls = parseJson( - generation.originalImageUrlsJson, - generation.originalImageUrl ? [generation.originalImageUrl] : [] - ); - - return { - generationId: Number(generation.generationId), - generationUid: generation.generationUid, - productName: generation.spu || '', - spu: generation.spu || '', - scene: generation.scene || '', - scenes: parseJson(generation.scenesJson, []), - userTypes: parseJson(generation.userTypesJson, []), - sellingPoints: generation.sellingPoints || '', - useEmoji: Number(generation.useEmoji) !== 0, - productImageUrls: originalImageUrls, - originalImageUrl: generation.originalImageUrl || originalImageUrls[0] || '', - originalImageUrls, - originalImageMimeType: generation.originalImageMimeType || '', - total: Number(generation.total || reviews.length), - targetTotal: Number(generation.targetTotal || generation.total || reviews.length), - imageAnalysis: parseJson(generation.imageAnalysisJson, {}), - reviewPrompt: generation.reviewPrompt || '', - operator: { - id: generation.operatorId || null, - name: generation.operatorName || '', - }, - createdAt: generation.createdAt, - reviews, - }; -} - -module.exports = { - AI_BUYER_SHOW_DATABASE_CONFIG_NAME, - buildDefaultPrompt, - createAiBuyerShowJob, - generateAiBuyerShowReviews, - getAiBuyerShowHistoryDetail, - getAiBuyerShowJob, - getPool: () => getPool(), - getQueueStats, - getWorkerRuntimeStatus, - listAiBuyerShowJobs, - listAiBuyerShowHistory, - query: (sql, params) => query(sql, params), - resolveUser, - testAiBuyerShowDatabase, -}; diff --git a/backend/src/services/csQualityAiPrompt.js b/backend/src/services/csQualityAiPrompt.js deleted file mode 100644 index 00583e9..0000000 --- a/backend/src/services/csQualityAiPrompt.js +++ /dev/null @@ -1,111 +0,0 @@ -'use strict'; - -const PRE_SALE_LABELS = [ - '库存备货:是否有货', - '发货问题:发货地、发货速度、缺货问题、催发货、延期发货', - '批量外贸单:贴唛头', - '快递问题:是否包邮、指定快递、送货上门、物流速度、配送速度、到货时间', - '产品问题:是否防潮/防火、是否有背板/轨道、材质的区别、产地问题、质保时效、尺寸问题、板材厚度、是否支持定制、承重性', - '产品环保问题:检测报告、气味问题', - '价格问题:优惠券问题、买后比价、商品降价', - '下单错误:多拍错拍、地址/电话信息错误', - '决策信息不充分:尺码/尺寸/规格不合适', - '安装问题:安装说明书/安装视频、是否包安装、是否整装发货', - '发票问题:是否有发票、多开/少开发票、催发票', -]; - -const AFTER_SALE_LABELS = [ - '发货履约问题:发货异常、虚假发货', - '物流配送问题:更新超时、丢件、派送超时、送货异常、退货到仓', - '商品交付问题:少发漏发、错发、运输产品破损', - '商品质量问题:产品质量、描述不符', - '安装服务问题:安装咨询、上门安装', - '客服服务问题:回复超时、服务态度极差、承诺未履行、操作错误', - '退换货问题:上门取件', - '平台业务问题:铺货错误、种菜异常', - '其他售后:其他', -]; - -function buildSystemPrompt() { - return [ - '你是拼多多电商客服聊天质检与商品问题分析专家。', - '你的任务是分析一个商品ID在指定日期范围内的全部客服聊天,识别售前和售后诉求,并把结果聚合成可用于产品改进、详情页优化和售后管理的结构化数据。', - '', - '【发言方识别】', - '1. 每条消息已经提供“买家”或“客服”角色,优先使用该角色。', - '2. 客服寒暄、安抚、礼貌语和标准话术不能单独作为证据;客服明确确认已超时、已登记、已拒绝、承诺未履行时可辅助举证。', - '3. 归因以买家诉求为主,不能把客服建议或猜测当成买家问题。', - '', - '【售前/售后判定】', - '1. 出现“订单编号:xxx”“订单号:xxx”或订单卡片后,从该位置开始的后续买家内容按售后语境处理;之前内容独立判断。', - '2. 未出现订单号时,根据语义判断:下单前的购买决策咨询属于售前;已付款、已收货或明确反馈已发生问题属于售后。', - '3. “多久发货”在下单前是售前;“为什么还不发货”或“催发货”通常是售后。', - '4. 同一诉求无法区分时售后优先。否定、假设、举例、转述他人或已经解决的问题不能作为当前问题。', - '', - '【售前标签白名单】', - ...PRE_SALE_LABELS, - '售前无法匹配时使用 primary_reason=“未知”、secondary_reason=“其他问题”。', - '', - '【售后标签白名单】', - ...AFTER_SALE_LABELS, - '售后无法匹配时使用 primary_reason=“其他售后”、secondary_reason=“其他”。', - '', - '【标签边界】', - '商品与页面描述、宣传或明确承诺不一致,使用“商品质量问题/描述不符”;商品本身缺陷、故障、开裂、变形,使用“商品质量问题/产品质量”;发错型号、款式、颜色或规格,使用“商品交付问题/错发”;缺少商品或配件,使用“商品交付问题/少发漏发”;运输造成磕碰、断裂或包装损坏,使用“商品交付问题/运输产品破损”。', - '', - '【聚合规则】', - '1. 相同一级原因+二级原因的诉求合并为一条结果,统计 occurrence_count 和 affected_customer_count。', - '2. 不同语义的独立问题分别输出,例如少发漏发和运输破损不能合并。', - '3. 每条结果最多保留3条最有代表性的买家原话证据,证据不得改写或拼接。', - '4. affected_conversation_ids 必须列出当前问题涉及的全部会话ID(uid),不得只列关键证据中的客户,也不得填入无关客户。', - '5. 买家消息中的图片URL只提取与当前问题直接相关的图片,每条结果最多5张,必须原样保留。', - '6. product_improvement_areas只能从:产品本体、规格配置、宣传主图、详情页、客服话术、价格策略、库存备货、履约物流、安装服务、售后服务、无需改进 中选择。', - '', - '【严格输出】', - '只能输出合法JSON,不要输出Markdown代码块、解释或额外文字。JSON结构必须是:', - JSON.stringify({ - schema_version: '1.0', - conversation_type: '售前|售后|混合|无有效咨询', - summary: { - conversation_count: 0, - pre_sale_conversation_count: 0, - after_sale_conversation_count: 0, - mixed_conversation_count: 0, - main_findings: [], - }, - results: [{ - consultation_type: '售前|售后', - primary_reason: '一级原因', - secondary_reason: '二级原因', - occurrence_count: 1, - affected_customer_count: 1, - affected_conversation_ids: ['uid'], - buyer_intent: '买家诉求概括', - evidence: [{ conversation_id: 'uid', nickname: '买家昵称', message_time: '消息时间', text: '买家原文' }], - image_urls: [], - product_improvement_areas: [], - improvement_suggestion: '', - confidence: 0.95, - }], - }, null, 2), - ].join('\n'); -} - -function buildUserPrompt({ product = {}, metrics = {}, chatText = '', startDate, endDate }) { - return [ - `请分析商品ID:${product.goodsId || ''}`, - `商品名称:${product.goodsName || ''}`, - `所属店铺:${product.shopName || ''}`, - `图片地址:${product.hdThumbUrl || ''}`, - `分析日期:${startDate} 至 ${endDate}`, - `商品表现指标:${JSON.stringify(metrics)}`, - '', - '以下是该商品在指定日期范围内的聊天记录。每条记录已经标注角色,请结合上下文完成售前/售后识别、标签映射和问题聚合。', - chatText, - ].join('\n'); -} - -module.exports = { - buildSystemPrompt, - buildUserPrompt, -}; diff --git a/backend/src/services/csQualityService.js b/backend/src/services/csQualityService.js deleted file mode 100644 index a762717..0000000 --- a/backend/src/services/csQualityService.js +++ /dev/null @@ -1,1967 +0,0 @@ -'use strict'; - -const axios = require('axios'); -const crypto = require('crypto'); -const database = require('../config/database'); -const cloudDatabase = require('../config/cloudDatabase'); -const reportDatabase = require('../config/reportReplicaDatabase'); -const { buildSystemPrompt, buildUserPrompt } = require('./csQualityAiPrompt'); -const dataPageCache = require('./dataPageCache'); - -const CHAT_TABLE = '[rpt].[pdd_GoodsChatMessages]'; -const GOODS_TABLE = '[rpt].[pdd_DataGoods]'; -const HISTORY_TABLE = '[app].[pdd_chat_ai_analysis_history]'; -const HISTORY_SCHEMA_VERSION = '1.0'; -const MAX_ANALYSIS_MESSAGES = 10000; -const MAX_ANALYSIS_TEXT_LENGTH = 2000000; -const QWEN_PROVIDER = 'aliyun-model-studio'; -const AGNES_PROVIDER = 'agnes-ai-cn'; -const AI_CONFIG_KEYS = [ - 'cs_quality_ai_config', - 'agnes_quality_ai_config', - 'qwen_quality_ai_config', - 'selection_fission_ai_analysis_config', -]; -const DEFAULT_QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; -const DEFAULT_QWEN_MODEL = 'qwen3.7-max-2026-06-08'; -const DEFAULT_AGNES_BASE_URL = 'https://api.agnes-ai.cn/v1'; -const DEFAULT_AGNES_MODEL = 'agnes-2.5-flash'; -const DEFAULT_BATCH_MAX_CHARS = 45000; -const DEFAULT_BATCH_CONCURRENCY = 2; -const DEFAULT_BATCH_MAX_TOKENS = 8000; -const DEFAULT_MERGE_MAX_TOKENS = 12000; -const DEFAULT_MERGE_MAX_BATCHES = 8; -const DEFAULT_AI_REQUEST_RETRY_COUNT = 2; -const DEFAULT_AI_RETRY_BASE_DELAY_MS = 1500; -const MAX_AI_RETRY_DELAY_MS = 30000; -const CS_QUALITY_CACHE_VERSION = 'v2'; -const CS_QUALITY_MEMORY_CACHE_TTL_SECONDS = Math.max( - Number.parseInt(process.env.CS_QUALITY_MEMORY_CACHE_TTL_SECONDS || '600', 10) || 600, - 30 -); -const CS_QUALITY_REDIS_CACHE_TTL_SECONDS = Math.max( - Number.parseInt(process.env.CS_QUALITY_REDIS_CACHE_TTL_SECONDS || '3600', 10) || 3600, - CS_QUALITY_MEMORY_CACHE_TTL_SECONDS -); -const CS_QUALITY_SNAPSHOT_MAX_DAYS = Math.min(Math.max( - Number.parseInt(process.env.CS_QUALITY_SNAPSHOT_MAX_DAYS || '3', 10) || 3, - 1 -), 7); -const CS_QUALITY_JOB_STALE_MS = Math.max( - Number.parseInt(process.env.CS_QUALITY_JOB_STALE_MS || String(45 * 60 * 1000), 10) || 45 * 60 * 1000, - 15 * 60 * 1000 -); -const IMPROVEMENT_AREAS = new Set(['产品本体', '规格配置', '宣传主图', '详情页', '客服话术', '价格策略', '库存备货', '履约物流', '安装服务', '售后服务', '无需改进']); -const IMAGE_URL_PATTERN = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i; - -const PRE_SALE_MAP = new Map([ - ['是否有货', '库存备货'], - ['发货地', '发货问题'], ['发货速度', '发货问题'], ['缺货问题', '发货问题'], ['催发货', '发货问题'], ['延期发货', '发货问题'], - ['贴唛头', '批量外贸单'], - ['是否包邮', '快递问题'], ['指定快递', '快递问题'], ['送货上门', '快递问题'], ['物流速度', '快递问题'], ['配送速度', '快递问题'], ['到货时间', '快递问题'], - ['是否防潮/防火', '产品问题'], ['是否有背板/轨道', '产品问题'], ['材质的区别', '产品问题'], ['产地问题', '产品问题'], ['质保时效', '产品问题'], ['尺寸问题', '产品问题'], ['板材厚度', '产品问题'], ['是否支持定制', '产品问题'], ['承重性', '产品问题'], - ['检测报告', '产品环保问题'], ['气味问题', '产品环保问题'], - ['优惠券问题', '价格问题'], ['买后比价', '价格问题'], ['商品降价', '价格问题'], - ['多拍错拍', '下单错误'], ['地址/电话信息错误', '下单错误'], - ['尺码/尺寸/规格不合适', '决策信息不充分'], - ['安装说明书/安装视频', '安装问题'], ['是否包安装', '安装问题'], ['是否整装发货', '安装问题'], - ['是否有发票', '发票问题'], ['多开/少开发票', '发票问题'], ['催发票', '发票问题'], -]); - -const AFTER_SALE_PRIMARY = { - 发货异常: '发货履约问题', 虚假发货: '发货履约问题', - 更新超时: '物流配送问题', 丢件: '物流配送问题', 派送超时: '物流配送问题', 送货异常: '物流配送问题', 退货到仓: '物流配送问题', - 少发漏发: '商品交付问题', 错发: '商品交付问题', 运输产品破损: '商品交付问题', - 产品质量: '商品质量问题', 描述不符: '商品质量问题', - 安装咨询: '安装服务问题', 上门安装: '安装服务问题', - 回复超时: '客服服务问题', 服务态度极差: '客服服务问题', 承诺未履行: '客服服务问题', 操作错误: '客服服务问题', - 上门取件: '退换货问题', 铺货错误: '平台业务问题', 种菜异常: '平台业务问题', 其他: '其他售后', -}; -const AFTER_SALE_LABELS = new Set(Object.keys(AFTER_SALE_PRIMARY)); - -let historyClientPromise = null; -let historySchemaPromise = null; - -function normalizeText(value, maxLength = 2000) { - return String(value ?? '').trim().slice(0, maxLength); -} - -function parseJson(value, fallback = null) { - if (value && typeof value === 'object') return value; - try { return JSON.parse(String(value || '')); } catch (_) { return fallback; } -} - -function boundedInteger(value, fallback, min, max) { - const number = Number(value); - if (!Number.isFinite(number)) return fallback; - return Math.min(Math.max(Math.trunc(number), min), max); -} - -function parseDateOnly(value) { - const text = normalizeText(value, 10); - if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return null; - const timestamp = Date.parse(`${text}T00:00:00Z`); - return Number.isFinite(timestamp) ? text : null; -} - -function addDate(dateText, amount) { - const date = new Date(`${dateText}T00:00:00Z`); - date.setUTCDate(date.getUTCDate() + amount); - return date.toISOString().slice(0, 10); -} - -function resolveDateRange(startDate, endDate) { - const today = new Date(); - const fallback = new Date(today.getTime() - 86400000).toISOString().slice(0, 10); - const start = parseDateOnly(startDate) || fallback; - const end = parseDateOnly(endDate) || start; - const dayCount = Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1; - if (dayCount < 1) throw new Error('开始日期不能晚于结束日期'); - if (dayCount > 30) throw new Error('聊天记录分析日期范围不能超过30天'); - return { startDate: start, endDate: end, endExclusiveDate: addDate(end, 1), startAt: `${start} 00:00:00`, endExclusive: `${addDate(end, 1)} 00:00:00`, dayCount }; -} - -function normalizeGoodsId(value) { - const text = normalizeText(value, 80); - if (!text || !/^[0-9]+$/.test(text)) throw new Error('商品ID格式不正确'); - return text; -} - -function numberValue(value) { - const number = Number(String(value ?? '').replace(/,/g, '').replace('%', '')); - return Number.isFinite(number) ? number : 0; -} - -function percentValue(value) { - const number = numberValue(value); - return number; -} - -function formatMetric(value, digits = 2) { - const number = Number(value); - return Number.isFinite(number) ? Number(number.toFixed(digits)) : 0; -} - -function buildDateWhere(alias, range) { - return `${alias}.[createTime] >= @startDate AND ${alias}.[createTime] < @endExclusiveDate`; -} - -function extractMessageDate(value) { - const match = String(value || '').match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/); - if (!match) return null; - const dateText = `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`; - return parseDateOnly(dateText); -} - -function formatProgressDate(value) { - const date = parseDateOnly(value); - return date ? `${date.slice(5, 7)}月${date.slice(8, 10)}日` : ''; -} - -function formatBatchDateRange(batch = {}) { - const start = formatProgressDate(batch.startDate); - const end = formatProgressDate(batch.endDate); - if (!start && !end) return '当前批次'; - if (!end || start === end) return start || end; - return `${start}至${end}`; -} - -function isTruthy(value) { - return ['1', 'true', 'yes', 'on'].includes(normalizeText(value, 10).toLowerCase()); -} - -function selectMaxText(current, candidate, maxLength = 2000) { - const next = normalizeText(candidate, maxLength); - if (!next) return current || null; - if (!current || next > current) return next; - return current; -} - -function selectTime(current, candidate, mode) { - if (!candidate) return current || null; - if (!current) return candidate; - const currentTime = new Date(current).getTime(); - const candidateTime = new Date(candidate).getTime(); - if (Number.isFinite(currentTime) && Number.isFinite(candidateTime)) { - return mode === 'min' - ? (candidateTime < currentTime ? candidate : current) - : (candidateTime > currentTime ? candidate : current); - } - const currentText = String(current); - const candidateText = String(candidate); - return mode === 'min' - ? (candidateText < currentText ? candidate : current) - : (candidateText > currentText ? candidate : current); -} - -function aggregateChatDimensionRows(rows = []) { - const goodsMap = new Map(); - const customerMaps = new Map(); - - for (const row of rows) { - const goodsId = normalizeText(row.goodsId, 80); - if (!goodsId) continue; - const uid = normalizeText(row.uid, 200); - const csname = normalizeText(row.csname, 200); - const messageCount = Math.max(0, Number(row.messageCount) || 0); - - let goods = goodsMap.get(goodsId); - if (!goods) { - goods = { - goodsId, - goodsName: null, - hdThumbUrl: null, - shopName: null, - messageCount: 0, - userIds: new Set(), - csNames: new Set(), - reportedCsCount: 0, - chatStartAt: null, - chatEndAt: null, - }; - goodsMap.set(goodsId, goods); - } - goods.goodsName = selectMaxText(goods.goodsName, row.goodsName); - goods.hdThumbUrl = selectMaxText(goods.hdThumbUrl, row.hdThumbUrl); - goods.shopName = selectMaxText(goods.shopName, row.shopName, 200); - goods.messageCount += messageCount; - if (uid) goods.userIds.add(uid); - if (csname) goods.csNames.add(csname); - goods.reportedCsCount = Math.max(goods.reportedCsCount, Number(row.csCount) || 0); - goods.chatStartAt = selectTime(goods.chatStartAt, row.firstMsgTime, 'min'); - goods.chatEndAt = selectTime(goods.chatEndAt, row.lastMsgTime, 'max'); - - if (!uid) continue; - let customerMap = customerMaps.get(goodsId); - if (!customerMap) { - customerMap = new Map(); - customerMaps.set(goodsId, customerMap); - } - let customer = customerMap.get(uid); - if (!customer) { - customer = { - uid, - nickname: null, - csname: null, - messageCount: 0, - firstMsgTime: null, - lastMsgTime: null, - }; - customerMap.set(uid, customer); - } - customer.nickname = selectMaxText(customer.nickname, row.nickname, 200); - customer.csname = selectMaxText(customer.csname, csname, 200); - customer.messageCount += messageCount; - customer.firstMsgTime = selectTime(customer.firstMsgTime, row.firstMsgTime, 'min'); - customer.lastMsgTime = selectTime(customer.lastMsgTime, row.lastMsgTime, 'max'); - } - - const goodsRows = Array.from(goodsMap.values()) - .map((goods) => ({ - goodsId: goods.goodsId, - goodsName: goods.goodsName, - hdThumbUrl: goods.hdThumbUrl, - shopName: goods.shopName, - messageCount: goods.messageCount, - userCount: goods.userIds.size, - csCount: Math.max(goods.reportedCsCount, goods.csNames.size), - chatStartAt: goods.chatStartAt, - chatEndAt: goods.chatEndAt, - })) - .sort((left, right) => ( - right.userCount - left.userCount - || right.messageCount - left.messageCount - || left.goodsId.localeCompare(right.goodsId) - )) - .slice(0, 5000); - - const customersByGoods = {}; - for (const [goodsId, customerMap] of customerMaps.entries()) { - customersByGoods[goodsId] = Array.from(customerMap.values()) - .sort((left, right) => ( - right.messageCount - left.messageCount - || left.uid.localeCompare(right.uid) - )) - .slice(0, 5000); - } - - return { goodsRows, customersByGoods }; -} - -async function getCachedCsQualityPayload(namespace, payload, label, loader, ttl = {}) { - const key = dataPageCache.buildHashedCacheKey( - `cs-quality:${namespace}`, - payload, - CS_QUALITY_CACHE_VERSION - ); - return dataPageCache.getOrSetCachedPayload({ - key, - memoryTtlSeconds: ttl.memory || CS_QUALITY_MEMORY_CACHE_TTL_SECONDS, - redisTtlSeconds: ttl.redis || CS_QUALITY_REDIS_CACHE_TTL_SECONDS, - label, - loader, - shouldCache: ttl.shouldCache, - guardLoader: false, - }); -} - -async function getChatDimensionSnapshot(options, range, scopeGoodsId = '') { - const shopName = normalizeText(options.shopName, 200); - const params = { startDate: range.startDate, endExclusiveDate: range.endExclusiveDate }; - const conditions = [buildDateWhere('g', range)]; - if (shopName) { - params.shopName = `%${shopName}%`; - conditions.push('g.[shopName] LIKE @shopName'); - } - if (scopeGoodsId) { - params.scopeGoodsId = scopeGoodsId; - conditions.push('g.[goodsId] = @scopeGoodsId'); - } - - const cached = await getCachedCsQualityPayload( - 'dimension-snapshot', - { startDate: range.startDate, endDate: range.endDate, shopName, scopeGoodsId }, - 'cs-quality-dimension-snapshot', - async () => { - const rows = await reportDatabase.query( - `WITH grouped AS ( - SELECT - g.[goodsId] AS [goodsId], g.[uid] AS [uid], g.[csname] AS [csname], - MAX(NULLIF(g.[goodsName], N'')) AS [goodsName], - MAX(NULLIF(g.[hdThumbUrl], N'')) AS [hdThumbUrl], - MAX(NULLIF(g.[shopName], N'')) AS [shopName], - MAX(NULLIF(g.[nickname], N'')) AS [nickname], - COUNT_BIG(1) AS [messageCount], - MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [firstMsgTime], - MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [lastMsgTime] - FROM ${CHAT_TABLE} g WITH (NOLOCK) - WHERE ${conditions.join(' AND ')} - GROUP BY g.[goodsId], g.[uid], g.[csname] - ), ranked AS ( - SELECT grouped.*, - DENSE_RANK() OVER ( - PARTITION BY grouped.[goodsId] - ORDER BY CASE WHEN NULLIF(LTRIM(RTRIM(grouped.[csname])), N'') IS NULL THEN 0 ELSE 1 END, - grouped.[csname] - ) AS [csRank], - MAX(CASE WHEN NULLIF(LTRIM(RTRIM(grouped.[csname])), N'') IS NULL THEN 1 ELSE 0 END) - OVER (PARTITION BY grouped.[goodsId]) AS [hasBlankCs] - FROM grouped - ) - SELECT - ranked.[goodsId] AS [goodsId], ranked.[uid] AS [uid], - MAX(NULLIF(ranked.[csname], N'')) AS [csname], - MAX(ranked.[goodsName]) AS [goodsName], - MAX(ranked.[hdThumbUrl]) AS [hdThumbUrl], - MAX(ranked.[shopName]) AS [shopName], - MAX(ranked.[nickname]) AS [nickname], - SUM(ranked.[messageCount]) AS [messageCount], - MIN(ranked.[firstMsgTime]) AS [firstMsgTime], - MAX(ranked.[lastMsgTime]) AS [lastMsgTime], - MAX(ranked.[csRank] - ranked.[hasBlankCs]) AS [csCount] - FROM ranked - GROUP BY ranked.[goodsId], ranked.[uid]`, - params, - { requestTimeout: 120000 } - ); - return aggregateChatDimensionRows(rows || []); - }, - { shouldCache: (value) => Array.isArray(value?.goodsRows) && value.goodsRows.length > 0 } - ); - - return { ...cached.value, cacheHit: cached.hit }; -} - -function mapChatDataMeta(row = {}) { - return { - tableName: 'rpt.pdd_GoodsChatMessages', - minDate: extractMessageDate(row.minDate), - latestDate: extractMessageDate(row.latestDate), - messageCount: Math.max(0, Number(row.messageCount) || 0), - }; -} - -async function getChatDataMeta() { - const cached = await getCachedCsQualityPayload( - 'data-meta', - {}, - 'cs-quality-data-meta', - async () => { - const rows = await reportDatabase.query( - `SELECT - MIN(NULLIF(LTRIM(RTRIM(g.[createTime])), N'')) AS [minDate], - MAX(NULLIF(LTRIM(RTRIM(g.[createTime])), N'')) AS [latestDate], - COUNT_BIG(1) AS [messageCount] - FROM ${CHAT_TABLE} g WITH (NOLOCK)`, - {}, - { requestTimeout: 120000 } - ); - return mapChatDataMeta(rows[0]); - }, - { memory: 300, redis: 300 } - ); - - return { ...cached.value, cache: { hit: cached.hit } }; -} - -async function getGoods(options = {}) { - const range = resolveDateRange(options.startDate, options.endDate); - const shopName = normalizeText(options.shopName, 200); - const scopeGoodsId = normalizeText(options.goodsId, 80) ? normalizeGoodsId(options.goodsId) : ''; - const canUseSnapshot = range.dayCount <= CS_QUALITY_SNAPSHOT_MAX_DAYS || Boolean(scopeGoodsId); - - if (canUseSnapshot) { - const snapshot = await getChatDimensionSnapshot(options, range, scopeGoodsId); - const result = { - tableName: 'rpt.pdd_GoodsChatMessages', - range, - rows: snapshot.goodsRows || [], - cache: { hit: snapshot.cacheHit }, - }; - if (isTruthy(options.includeInitial) && result.rows[0]) { - const firstGoods = result.rows[0]; - const customers = snapshot.customersByGoods?.[String(firstGoods.goodsId)] || []; - const firstCustomer = customers[0] || null; - let messages = []; - let messagesPrefetched = !firstCustomer; - if (firstCustomer) { - try { - const messageResult = await getMessages({ - startDate: range.startDate, - endDate: range.endDate, - shopName, - goodsId: firstGoods.goodsId, - uid: firstCustomer.uid, - }); - messages = messageResult.rows || []; - messagesPrefetched = true; - } catch (error) { - console.warn('[CsQuality] 首屏聊天预取失败:', error.message); - } - } - result.initial = { - goodsId: firstGoods.goodsId, - customerUid: firstCustomer?.uid || null, - customers, - messages, - messagesPrefetched, - }; - } - return result; - } - - const params = { startDate: range.startDate, endExclusiveDate: range.endExclusiveDate }; - const conditions = [buildDateWhere('g', range)]; - if (shopName) { - params.shopName = `%${shopName}%`; - conditions.push('g.[shopName] LIKE @shopName'); - } - if (scopeGoodsId) { - params.goodsId = scopeGoodsId; - conditions.push('g.[goodsId] = @goodsId'); - } - - const cached = await getCachedCsQualityPayload( - 'goods', - { startDate: range.startDate, endDate: range.endDate, shopName, scopeGoodsId }, - 'cs-quality-goods', - () => reportDatabase.query( - `SELECT TOP 5000 - g.[goodsId] AS [goodsId], - MAX(NULLIF(g.[goodsName], N'')) AS [goodsName], - MAX(NULLIF(g.[hdThumbUrl], N'')) AS [hdThumbUrl], - MAX(NULLIF(g.[shopName], N'')) AS [shopName], - COUNT_BIG(1) AS [messageCount], - COUNT(DISTINCT g.[uid]) AS [userCount], - COUNT(DISTINCT g.[csname]) AS [csCount], - MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatStartAt], - MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatEndAt] - FROM ${CHAT_TABLE} g WITH (NOLOCK) - WHERE ${conditions.join(' AND ')} - GROUP BY g.[goodsId] - ORDER BY COUNT(DISTINCT g.[uid]) DESC, COUNT_BIG(1) DESC`, - params, - { requestTimeout: 120000 } - ), - { shouldCache: (value) => Array.isArray(value) && value.length > 0 } - ); - - return { - tableName: 'rpt.pdd_GoodsChatMessages', - range, - rows: cached.value || [], - cache: { hit: cached.hit }, - }; -} - -async function getCustomers(options = {}) { - const goodsId = normalizeGoodsId(options.goodsId); - const range = resolveDateRange(options.startDate, options.endDate); - const shopName = normalizeText(options.shopName, 200); - const requestedScopeGoodsId = normalizeText(options.snapshotScopeGoodsId, 80); - const scopeGoodsId = requestedScopeGoodsId === goodsId ? goodsId : ''; - const canUseSnapshot = range.dayCount <= CS_QUALITY_SNAPSHOT_MAX_DAYS || Boolean(scopeGoodsId); - if (canUseSnapshot) { - const snapshot = await getChatDimensionSnapshot(options, range, scopeGoodsId); - return { - range, - rows: snapshot.customersByGoods?.[goodsId] || [], - cache: { hit: snapshot.cacheHit }, - }; - } - - const params = { goodsId, startDate: range.startDate, endExclusiveDate: range.endExclusiveDate }; - const conditions = [buildDateWhere('g', range), 'g.[goodsId] = @goodsId']; - if (shopName) { - params.shopName = `%${shopName}%`; - conditions.push('g.[shopName] LIKE @shopName'); - } - const cached = await getCachedCsQualityPayload( - 'customers', - { startDate: range.startDate, endDate: range.endDate, shopName, goodsId }, - 'cs-quality-customers', - () => reportDatabase.query( - `SELECT TOP 5000 - g.[uid] AS [uid], - MAX(NULLIF(g.[nickname], N'')) AS [nickname], - MAX(NULLIF(g.[csname], N'')) AS [csname], - COUNT_BIG(1) AS [messageCount], - MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [firstMsgTime], - MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [lastMsgTime] - FROM ${CHAT_TABLE} g WITH (NOLOCK) - WHERE ${conditions.join(' AND ')} - GROUP BY g.[uid] - ORDER BY COUNT_BIG(1) DESC`, - params, - { requestTimeout: 120000 } - ), - { shouldCache: (value) => Array.isArray(value) && value.length > 0 } - ); - return { range, rows: cached.value || [], cache: { hit: cached.hit } }; -} - -async function getMessages(options = {}) { - const goodsId = normalizeGoodsId(options.goodsId); - const range = resolveDateRange(options.startDate, options.endDate); - const uid = normalizeText(options.uid, 200); - const shopName = normalizeText(options.shopName, 200); - const params = { goodsId, startDate: range.startDate, endExclusiveDate: range.endExclusiveDate }; - const conditions = [buildDateWhere('g', range), 'g.[goodsId] = @goodsId']; - if (uid) { - params.uid = uid; - conditions.push('g.[uid] = @uid'); - } - if (shopName) { - params.shopName = `%${shopName}%`; - conditions.push('g.[shopName] LIKE @shopName'); - } - const loader = () => reportDatabase.query( - `SELECT TOP 20000 - g.[shopName] AS [shopName], g.[goodsId] AS [goodsId], g.[goodsName] AS [goodsName], - g.[uid] AS [uid], g.[nickname] AS [nickname], g.[msgId] AS [msgId], - g.[msgTime] AS [msgTime], g.[msgType] AS [msgType], - CASE WHEN ISNULL(g.[direction], 0) = 1 THEN N'客服' ELSE N'买家' END AS [directionLabel], - g.[csname] AS [csname], g.[content] AS [content] - FROM ${CHAT_TABLE} g WITH (NOLOCK) - WHERE ${conditions.join(' AND ')} - ORDER BY TRY_CONVERT(DATETIME2, g.[msgTime]) ASC, g.[id] ASC`, - params, - { requestTimeout: 120000 } - ); - if (!uid) return { range, rows: await loader() }; - - const cached = await getCachedCsQualityPayload( - 'messages', - { startDate: range.startDate, endDate: range.endDate, shopName, goodsId, uid }, - 'cs-quality-messages', - loader, - { - memory: 300, - redis: 900, - shouldCache: (value) => Array.isArray(value) && value.length > 0, - } - ); - return { range, rows: cached.value || [], cache: { hit: cached.hit } }; -} - -async function getProductProfile(options = {}) { - const goodsId = normalizeGoodsId(options.goodsId); - const range = resolveDateRange(options.startDate, options.endDate); - const params = { goodsId, startDate: range.startDate, endDate: range.endDate }; - const [latestRows, metricRows, chatRows] = await Promise.all([ - reportDatabase.query( - `SELECT TOP 1 - d.[goodsId] AS [goodsId], d.[goodsName] AS [goodsName], d.[shopName] AS [shopName], - d.[hdThumbUrl] AS [hdThumbUrl], d.[cate1Name] AS [cate1Name], d.[cate2Name] AS [cate2Name], d.[cate3Name] AS [cate3Name], - d.[createTime] AS [metricDate] - FROM ${GOODS_TABLE} d WITH (NOLOCK) - WHERE d.[goodsId] = TRY_CONVERT(BIGINT, @goodsId) AND d.[createTime] <= @endDate - ORDER BY TRY_CONVERT(DATE, d.[createTime]) DESC, d.[createdAt] DESC`, - params, - { requestTimeout: 120000 } - ), - reportDatabase.query( - `SELECT - COUNT_BIG(1) AS [metricDays], - SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsUv], N',', N''))) AS [goodsUv], - SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsPv], N',', N''))) AS [goodsPv], - SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrAmt], N',', N''))) AS [payOrdrAmt], - SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrGoodsQty], N',', N''))) AS [payOrdrGoodsQty], - SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrCnt], N',', N''))) AS [payOrdrCnt], - SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrUsrCnt], N',', N''))) AS [payOrdrUsrCnt], - SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsFavCnt], N',', N''))) AS [goodsFavCnt], - AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsVcr], N'%',''))) AS [goodsVcr], - AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[ordrVstrRto], N'%',''))) AS [ordrVstrRto], - AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrRto], N'%',''))) AS [payOrdrRto], - AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsPtHelpRate], N'%',''))) AS [goodsPtHelpRate] - FROM ${GOODS_TABLE} d WITH (NOLOCK) - WHERE d.[goodsId] = TRY_CONVERT(BIGINT, @goodsId) - AND d.[createTime] >= @startDate AND d.[createTime] <= @endDate`, - params, - { requestTimeout: 120000 } - ), - reportDatabase.query( - `SELECT COUNT_BIG(1) AS [messageCount], COUNT(DISTINCT g.[uid]) AS [userCount], - MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatStartAt], - MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatEndAt] - FROM ${CHAT_TABLE} g WITH (NOLOCK) - WHERE g.[goodsId] = @goodsId AND g.[createTime] >= @startDate AND g.[createTime] < @endExclusiveDate`, - { goodsId, startDate: range.startDate, endExclusiveDate: range.endExclusiveDate }, - { requestTimeout: 120000 } - ), - ]); - - const metrics = metricRows[0] || {}; - return { - range, - product: latestRows[0] || { goodsId }, - metrics: { - metricDays: Number(metrics.metricDays || 0), - goodsUv: formatMetric(metrics.goodsUv, 0), goodsPv: formatMetric(metrics.goodsPv, 0), - payOrdrAmt: formatMetric(metrics.payOrdrAmt), payOrdrGoodsQty: formatMetric(metrics.payOrdrGoodsQty, 0), - payOrdrCnt: formatMetric(metrics.payOrdrCnt, 0), payOrdrUsrCnt: formatMetric(metrics.payOrdrUsrCnt, 0), - goodsFavCnt: formatMetric(metrics.goodsFavCnt, 0), goodsVcr: formatMetric(metrics.goodsVcr), - ordrVstrRto: formatMetric(metrics.ordrVstrRto), payOrdrRto: formatMetric(metrics.payOrdrRto), - goodsPtHelpRate: formatMetric(metrics.goodsPtHelpRate), - }, - chatSummary: chatRows[0] || { messageCount: 0, userCount: 0 }, - }; -} - -async function getHistoryClient() { - if (!historyClientPromise) { - historyClientPromise = cloudDatabase.createPinnedQueryClient({ - preferredProfile: 'public', - verifySql: `SELECT HAS_PERMS_BY_NAME(N'app', N'SCHEMA', N'INSERT') AS canInsert`, - verifyResult: (rows) => Number(rows?.[0]?.canInsert) === 1, - verifyFailureMessage: '公网业务数据库 app schema 没有写入权限', - }).catch((error) => { - historyClientPromise = null; - throw error; - }); - } - return historyClientPromise; -} - -async function ensureHistorySchema() { - if (!historySchemaPromise) { - historySchemaPromise = getHistoryClient().then((client) => client.query( - `IF OBJECT_ID(N'app.pdd_chat_ai_analysis_history', N'U') IS NULL - BEGIN - CREATE TABLE ${HISTORY_TABLE} ( - id BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY, - analysis_id NVARCHAR(64) NOT NULL, - status NVARCHAR(20) NOT NULL, - goods_id NVARCHAR(80) NOT NULL, - goods_name NVARCHAR(500) NULL, - shop_name NVARCHAR(255) NULL, - product_image_url NVARCHAR(2000) NULL, - chat_start_date DATE NOT NULL, - chat_end_date DATE NOT NULL, - source_message_count INT NOT NULL DEFAULT 0, - source_customer_count INT NOT NULL DEFAULT 0, - product_metrics_json NVARCHAR(MAX) NULL, - request_prompt NVARCHAR(MAX) NULL, - ai_provider NVARCHAR(80) NULL, - ai_model NVARCHAR(120) NULL, - ai_response_json NVARCHAR(MAX) NULL, - ai_raw_response_json NVARCHAR(MAX) NULL, - conversation_type NVARCHAR(30) NULL, - analysis_json NVARCHAR(MAX) NULL, - error_message NVARCHAR(2000) NULL, - duration_ms INT NULL, - prompt_tokens INT NULL, - completion_tokens INT NULL, - total_tokens INT NULL, - progress_stage NVARCHAR(40) NULL, - progress_current INT NULL, - progress_total INT NULL, - progress_percent INT NULL, - progress_message NVARCHAR(500) NULL, - progress_updated_at DATETIME2(3) NULL, - created_by_user_id NVARCHAR(100) NULL, - created_by_name NVARCHAR(100) NULL, - created_at DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(), - updated_at DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME() - ); - END - IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_stage') IS NULL - ALTER TABLE ${HISTORY_TABLE} ADD progress_stage NVARCHAR(40) NULL; - IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_current') IS NULL - ALTER TABLE ${HISTORY_TABLE} ADD progress_current INT NULL; - IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_total') IS NULL - ALTER TABLE ${HISTORY_TABLE} ADD progress_total INT NULL; - IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_percent') IS NULL - ALTER TABLE ${HISTORY_TABLE} ADD progress_percent INT NULL; - IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_message') IS NULL - ALTER TABLE ${HISTORY_TABLE} ADD progress_message NVARCHAR(500) NULL; - IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_updated_at') IS NULL - ALTER TABLE ${HISTORY_TABLE} ADD progress_updated_at DATETIME2(3) NULL; - IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_pdd_chat_ai_analysis_history_analysis_id' AND object_id = OBJECT_ID(N'app.pdd_chat_ai_analysis_history')) - CREATE UNIQUE INDEX UX_pdd_chat_ai_analysis_history_analysis_id ON ${HISTORY_TABLE}(analysis_id); - IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_pdd_chat_ai_analysis_history_goods_date' AND object_id = OBJECT_ID(N'app.pdd_chat_ai_analysis_history')) - CREATE INDEX IX_pdd_chat_ai_analysis_history_goods_date ON ${HISTORY_TABLE}(goods_id, chat_start_date, chat_end_date, created_at DESC); - IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_pdd_chat_ai_analysis_history_created_at' AND object_id = OBJECT_ID(N'app.pdd_chat_ai_analysis_history')) - CREATE INDEX IX_pdd_chat_ai_analysis_history_created_at ON ${HISTORY_TABLE}(created_at DESC);`, - {}, - { requestTimeout: 120000 } - )).catch((error) => { - historySchemaPromise = null; - throw error; - }); - } - await historySchemaPromise; -} - -function resolveAiProvider(config = {}) { - const hint = normalizeText( - config.provider || config.aiProvider || config.model || config.baseUrl || config.base_url, - 500 - ).toLowerCase(); - return hint.includes('agnes') ? AGNES_PROVIDER : QWEN_PROVIDER; -} - -function resolveAiConfig(raw) { - const config = raw && typeof raw === 'object' ? raw : {}; - const provider = resolveAiProvider(config); - const isAgnes = provider === AGNES_PROVIDER; - return { - provider, - baseUrl: normalizeText( - config.baseUrl || config.base_url - || (isAgnes ? process.env.AGNES_AI_BASE_URL : process.env.QWEN_AI_BASE_URL) - || (isAgnes ? DEFAULT_AGNES_BASE_URL : DEFAULT_QWEN_BASE_URL), - 500 - ).replace(/\/+$/, ''), - apiKey: normalizeText( - config.apiKey || config.api_key - || (isAgnes - ? process.env.AGNES_API_KEY - : process.env.QWEN_API_KEY || process.env.DASHSCOPE_API_KEY), - 4000 - ), - model: normalizeText( - config.model - || (isAgnes ? process.env.AGNES_AI_MODEL : process.env.QWEN_AI_MODEL) - || (isAgnes ? DEFAULT_AGNES_MODEL : DEFAULT_QWEN_MODEL), - 120 - ), - timeoutMs: boundedInteger( - config.timeoutMs || config.timeout_ms - || (isAgnes ? process.env.AGNES_AI_TIMEOUT_MS : process.env.QWEN_AI_TIMEOUT_MS), - isAgnes ? 600000 : 180000, - 30000, - 600000 - ), - batchMaxChars: boundedInteger( - config.batchMaxChars || config.batch_max_chars || process.env.CS_QUALITY_BATCH_MAX_CHARS, - DEFAULT_BATCH_MAX_CHARS, - 10000, - 200000 - ), - batchConcurrency: boundedInteger( - config.batchConcurrency || config.batch_concurrency || process.env.CS_QUALITY_BATCH_CONCURRENCY, - DEFAULT_BATCH_CONCURRENCY, - 1, - 5 - ), - batchMaxTokens: boundedInteger( - config.batchMaxTokens || config.batch_max_tokens || process.env.CS_QUALITY_BATCH_MAX_TOKENS, - DEFAULT_BATCH_MAX_TOKENS, - 1000, - 16000 - ), - mergeMaxTokens: boundedInteger( - config.mergeMaxTokens || config.merge_max_tokens || process.env.CS_QUALITY_MERGE_MAX_TOKENS, - DEFAULT_MERGE_MAX_TOKENS, - 1000, - 24000 - ), - mergeMaxBatches: boundedInteger( - config.mergeMaxBatches || config.merge_max_batches || process.env.CS_QUALITY_MERGE_MAX_BATCHES, - DEFAULT_MERGE_MAX_BATCHES, - 0, - 50 - ), - requestRetryCount: boundedInteger( - config.requestRetryCount || config.request_retry_count || process.env.CS_QUALITY_AI_REQUEST_RETRY_COUNT, - DEFAULT_AI_REQUEST_RETRY_COUNT, - 0, - 4 - ), - retryBaseDelayMs: boundedInteger( - config.retryBaseDelayMs || config.retry_base_delay_ms || process.env.CS_QUALITY_AI_RETRY_BASE_DELAY_MS, - DEFAULT_AI_RETRY_BASE_DELAY_MS, - 250, - 10000 - ), - }; -} - -async function getAiConfig() { - for (const key of AI_CONFIG_KEYS) { - const rows = await database.local.query('SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', [key]); - if (rows[0]?.configValue) { - const stored = parseJson(rows[0].configValue, {}); - if (stored?.enabled === false) continue; - const config = resolveAiConfig(stored); - if (config.apiKey) return config; - } - } - const config = resolveAiConfig({ provider: process.env.CS_QUALITY_AI_PROVIDER }); - if (!config.apiKey) { - const error = new Error('AI质检服务尚未配置,请联系管理员'); - error.statusCode = 503; - throw error; - } - return config; -} - -function parseModelJson(content) { - const text = String(content || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim(); - try { return JSON.parse(text); } catch (_) { - const start = text.indexOf('{'); - const end = text.lastIndexOf('}'); - if (start >= 0 && end > start) return JSON.parse(text.slice(start, end + 1)); - throw new Error('AI服务返回内容格式异常'); - } -} - -function normalizeAnalysis(raw, sourceCounts, options = {}) { - const data = raw && typeof raw === 'object' ? raw : {}; - const inputResults = Array.isArray(data.results) ? data.results : []; - const allowedConversationIds = new Set( - (Array.isArray(options.allowedConversationIds) ? options.allowedConversationIds : []) - .map((value) => normalizeText(value, 100)) - .filter(Boolean) - ); - const results = inputResults.map((item) => { - const consultationType = String(item?.consultation_type || item?.consultationType || '').includes('售后') ? '售后' : '售前'; - let primaryReason = normalizeText(item?.primary_reason || item?.primaryReason, 100) || '未知'; - let secondaryReason = normalizeText(item?.secondary_reason || item?.secondaryReason, 100); - if (consultationType === '售后') { - secondaryReason = AFTER_SALE_LABELS.has(secondaryReason) ? secondaryReason : '其他'; - primaryReason = AFTER_SALE_PRIMARY[secondaryReason] || '其他售后'; - } else { - primaryReason = PRE_SALE_MAP.get(secondaryReason) || '未知'; - if (!PRE_SALE_MAP.has(secondaryReason)) secondaryReason = '其他问题'; - } - const evidenceSource = Array.isArray(item?.evidence) ? item.evidence : (item?.evidence ? [{ text: item.evidence }] : []); - const evidence = evidenceSource.slice(0, 3).map((entry) => ({ - conversation_id: normalizeText(entry?.conversation_id || entry?.conversationId || '', 100), - nickname: normalizeText(entry?.nickname, 100), - message_time: normalizeText(entry?.message_time || entry?.messageTime, 40), - text: normalizeText(entry?.text || entry?.content || entry, 1000), - })).filter((entry) => entry.text); - const affectedConversationIdSource = Array.isArray(item?.affected_conversation_ids) - ? item.affected_conversation_ids - : Array.isArray(item?.affectedConversationIds) - ? item.affectedConversationIds - : []; - const affectedConversationIds = Array.from(new Set([ - ...affectedConversationIdSource, - ...evidence.map((entry) => entry.conversation_id), - ] - .map((value) => normalizeText(value, 100)) - .filter((value) => value && (allowedConversationIds.size === 0 || allowedConversationIds.has(value))))); - const areas = Array.isArray(item?.product_improvement_areas) - ? item.product_improvement_areas.filter((area) => IMPROVEMENT_AREAS.has(area)) - : []; - const imageUrls = Array.isArray(item?.image_urls) - ? item.image_urls.filter((url) => /^https?:\/\//i.test(String(url)) && IMAGE_URL_PATTERN.test(String(url))).slice(0, 5) - : []; - return { - consultation_type: consultationType, - primary_reason: primaryReason, - secondary_reason: secondaryReason, - occurrence_count: Math.max(1, Number(item?.occurrence_count) || 1), - affected_customer_count: Math.max(affectedConversationIds.length, Number(item?.affected_customer_count) || 1), - affected_conversation_ids: affectedConversationIds, - buyer_intent: normalizeText(item?.buyer_intent, 500), - evidence, - image_urls: imageUrls, - product_improvement_areas: areas, - improvement_suggestion: normalizeText(item?.improvement_suggestion, 1000), - confidence: Math.min(1, Math.max(0, Number(item?.confidence) || 0)), - }; - }).filter((item) => item.evidence.length || item.buyer_intent); - const hasPre = results.some((item) => item.consultation_type === '售前'); - const hasAfter = results.some((item) => item.consultation_type === '售后'); - return { - schema_version: HISTORY_SCHEMA_VERSION, - conversation_type: hasPre && hasAfter ? '混合' : hasAfter ? '售后' : hasPre ? '售前' : '无有效咨询', - summary: { - conversation_count: Number(data.summary?.conversation_count) || sourceCounts.customerCount, - pre_sale_conversation_count: Number(data.summary?.pre_sale_conversation_count) || 0, - after_sale_conversation_count: Number(data.summary?.after_sale_conversation_count) || 0, - mixed_conversation_count: Number(data.summary?.mixed_conversation_count) || 0, - main_findings: Array.isArray(data.summary?.main_findings) ? data.summary.main_findings.map((item) => normalizeText(item, 300)).filter(Boolean).slice(0, 10) : [], - }, - results, - }; -} - -function formatConversation(uid, items) { - const header = `【会话ID:${uid};买家昵称:${normalizeText(items[0]?.nickname, 100)}】`; - const lines = items.map((row) => ( - `[${normalizeText(row.msgTime, 40)}][${row.directionLabel}][客服:${normalizeText(row.csname, 100)}] ${normalizeText(row.content, 2000)}` - )); - return `${header}\n${lines.join('\n')}`; -} - -function buildConversationGroups(rows = []) { - const groups = new Map(); - rows.forEach((row, index) => { - const key = normalizeText(row.uid, 100) || `unknown-${normalizeText(row.msgId, 100) || index}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(row); - }); - return Array.from(groups.entries()).map(([conversationId, items]) => { - const text = formatConversation(conversationId, items); - const dates = items.map((item) => extractMessageDate(item.msgTime)).filter(Boolean).sort(); - return { - conversationId, - messageCount: items.length, - text, - charCount: text.length, - startDate: dates[0] || null, - endDate: dates[dates.length - 1] || null, - }; - }); -} - -function buildChatText(rows) { - return buildConversationGroups(rows).map((group) => group.text).join('\n\n'); -} - -function createChatBatches(rows, maxChars = DEFAULT_BATCH_MAX_CHARS) { - const limit = boundedInteger(maxChars, DEFAULT_BATCH_MAX_CHARS, 1000, 1000000); - const conversationGroups = buildConversationGroups(rows); - const batches = []; - let current = []; - let currentChars = 0; - - const flush = () => { - if (!current.length) return; - const text = current.map((group) => group.text).join('\n\n'); - batches.push({ - index: batches.length + 1, - text, - charCount: text.length, - messageCount: current.reduce((sum, group) => sum + group.messageCount, 0), - customerCount: current.length, - conversationIds: current.map((group) => group.conversationId), - oversizedConversation: current.length === 1 && current[0].charCount > limit, - startDate: current.map((group) => group.startDate).filter(Boolean).sort()[0] || null, - endDate: current.map((group) => group.endDate).filter(Boolean).sort().at(-1) || null, - }); - current = []; - currentChars = 0; - }; - - for (const group of conversationGroups) { - const separatorLength = current.length ? 2 : 0; - if (current.length && currentChars + separatorLength + group.charCount > limit) flush(); - current.push(group); - currentChars += (current.length > 1 ? 2 : 0) + group.charCount; - } - flush(); - return batches; -} - -function analysisResultKey(item = {}) { - return [item.consultation_type, item.primary_reason, item.secondary_reason].join('|'); -} - -function mergeBatchAnalysesLocally(analyses, sourceCounts) { - const resultMap = new Map(); - const findings = []; - const summary = { - conversation_count: sourceCounts.customerCount, - pre_sale_conversation_count: 0, - after_sale_conversation_count: 0, - mixed_conversation_count: 0, - main_findings: findings, - }; - - for (const analysis of analyses) { - const batchSummary = analysis?.summary || {}; - summary.pre_sale_conversation_count += Number(batchSummary.pre_sale_conversation_count) || 0; - summary.after_sale_conversation_count += Number(batchSummary.after_sale_conversation_count) || 0; - summary.mixed_conversation_count += Number(batchSummary.mixed_conversation_count) || 0; - for (const finding of batchSummary.main_findings || []) { - const normalizedFinding = normalizeText(finding, 300); - if (normalizedFinding && !findings.includes(normalizedFinding) && findings.length < 10) findings.push(normalizedFinding); - } - - for (const item of analysis?.results || []) { - const key = analysisResultKey(item); - const existing = resultMap.get(key); - if (!existing) { - resultMap.set(key, { - ...item, - evidence: [...(item.evidence || [])], - affected_conversation_ids: [...(item.affected_conversation_ids || [])], - image_urls: [...(item.image_urls || [])], - product_improvement_areas: [...(item.product_improvement_areas || [])], - }); - continue; - } - existing.occurrence_count += Number(item.occurrence_count) || 0; - existing.affected_customer_count += Number(item.affected_customer_count) || 0; - existing.affected_conversation_ids = Array.from(new Set([ - ...(existing.affected_conversation_ids || []), - ...(item.affected_conversation_ids || []), - ])); - existing.confidence = Math.max(Number(existing.confidence) || 0, Number(item.confidence) || 0); - for (const evidence of item.evidence || []) { - const evidenceKey = `${evidence.conversation_id}|${evidence.message_time}|${evidence.text}`; - const exists = existing.evidence.some((entry) => `${entry.conversation_id}|${entry.message_time}|${entry.text}` === evidenceKey); - if (!exists && existing.evidence.length < 3) existing.evidence.push(evidence); - } - existing.image_urls = Array.from(new Set([...existing.image_urls, ...(item.image_urls || [])])).slice(0, 5); - existing.product_improvement_areas = Array.from(new Set([ - ...existing.product_improvement_areas, - ...(item.product_improvement_areas || []), - ])); - } - } - - const results = Array.from(resultMap.values()); - const hasPre = results.some((item) => item.consultation_type === '售前'); - const hasAfter = results.some((item) => item.consultation_type === '售后'); - return { - schema_version: HISTORY_SCHEMA_VERSION, - conversation_type: hasPre && hasAfter ? '混合' : hasAfter ? '售后' : hasPre ? '售前' : '无有效咨询', - summary, - results, - }; -} - -function reconcileMergedAnalysis(modelAnalysis, localAnalysis, sourceCounts) { - const localByKey = new Map(localAnalysis.results.map((item) => [analysisResultKey(item), item])); - const reconciled = []; - const seen = new Set(); - for (const item of modelAnalysis.results) { - const key = analysisResultKey(item); - const local = localByKey.get(key); - if (!local || seen.has(key)) continue; - seen.add(key); - reconciled.push({ - ...item, - occurrence_count: local.occurrence_count, - affected_customer_count: local.affected_customer_count, - affected_conversation_ids: [...(local.affected_conversation_ids || [])], - }); - } - for (const local of localAnalysis.results) { - const key = analysisResultKey(local); - if (!seen.has(key)) reconciled.push(local); - } - const hasPre = reconciled.some((item) => item.consultation_type === '售前'); - const hasAfter = reconciled.some((item) => item.consultation_type === '售后'); - return { - ...modelAnalysis, - conversation_type: hasPre && hasAfter ? '混合' : hasAfter ? '售后' : hasPre ? '售前' : '无有效咨询', - summary: { - ...modelAnalysis.summary, - conversation_count: sourceCounts.customerCount, - pre_sale_conversation_count: localAnalysis.summary.pre_sale_conversation_count, - after_sale_conversation_count: localAnalysis.summary.after_sale_conversation_count, - mixed_conversation_count: localAnalysis.summary.mixed_conversation_count, - }, - results: reconciled, - }; -} - -function normalizeUsage(usage = {}) { - const promptTokens = Number(usage.prompt_tokens ?? usage.input_tokens) || 0; - const completionTokens = Number(usage.completion_tokens ?? usage.output_tokens) || 0; - return { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: Number(usage.total_tokens) || promptTokens + completionTokens, - }; -} - -function sumUsages(usages = []) { - return usages.reduce((total, usage) => { - const normalized = normalizeUsage(usage); - total.prompt_tokens += normalized.prompt_tokens; - total.completion_tokens += normalized.completion_tokens; - total.total_tokens += normalized.total_tokens; - return total; - }, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); -} - -function buildMergePrompt({ product, sourceCounts, localAnalysis }) { - return [ - `请汇总商品ID ${product.goodsId} 的分批聊天质检结果。`, - `完整数据共 ${sourceCounts.customerCount} 个客户、${sourceCounts.messageCount} 条消息。`, - '每个客户只存在于一个批次。请优化买家诉求概括、改进建议和主要发现,但必须遵守以下规则:', - '1. 不得新增或删除“咨询类型+一级原因+二级原因”的组合。', - '2. occurrence_count 和 affected_customer_count 必须原样保留。', - '3. 证据只能从输入中选择,不得改写、拼接或虚构。', - '4. 只能输出系统要求的合法 JSON。', - '', - `待汇总结果:${JSON.stringify(localAnalysis)}`, - ].join('\n'); -} - -function getAiErrorDetail(error) { - const detail = error?.response?.data?.error?.message - || error?.response?.data?.message - || error?.response?.data?.error - || error?.message; - const text = typeof detail === 'string' && detail.trim() - ? detail - : detail && typeof detail === 'object' - ? JSON.stringify(detail) - : error?.code || '未知错误'; - return normalizeText(text, 1000) - .replace(/Bearer\s+\S+/gi, 'Bearer [redacted]') - .replace(/sk-[A-Za-z0-9_-]{12,}/g, '[redacted]'); -} - -function isRetryableAiError(error) { - const status = Number(error?.response?.status || error?.statusCode || 0); - if ([408, 425, 429].includes(status) || status >= 500) return true; - const code = normalizeText(error?.code, 100).toUpperCase(); - return [ - 'ETIMEDOUT', - 'ECONNABORTED', - 'ECONNRESET', - 'ECONNREFUSED', - 'EAI_AGAIN', - 'ENOTFOUND', - 'EPIPE', - 'UND_ERR_CONNECT_TIMEOUT', - 'AI_RESPONSE_INVALID', - ].includes(code); -} - -function getAiRetryDelayMs(error, retryNumber, baseDelayMs = DEFAULT_AI_RETRY_BASE_DELAY_MS) { - const headers = error?.response?.headers; - const retryAfter = typeof headers?.get === 'function' - ? headers.get('retry-after') - : headers?.['retry-after']; - if (retryAfter !== undefined && retryAfter !== null && String(retryAfter).trim()) { - const seconds = Number(retryAfter); - if (Number.isFinite(seconds) && seconds >= 0) { - return Math.min(Math.ceil(seconds * 1000), MAX_AI_RETRY_DELAY_MS); - } - const retryAt = Date.parse(String(retryAfter)); - if (Number.isFinite(retryAt)) { - return Math.min(Math.max(retryAt - Date.now(), 0), MAX_AI_RETRY_DELAY_MS); - } - } - const base = boundedInteger(baseDelayMs, DEFAULT_AI_RETRY_BASE_DELAY_MS, 250, 10000); - return Math.min(base * (2 ** Math.max(0, Number(retryNumber) - 1)), MAX_AI_RETRY_DELAY_MS); -} - -function sleep(milliseconds) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} - -function buildAiRequestPayload(config, messages, maxCompletionTokens) { - const payload = { - model: config.model, - messages, - temperature: 0, - stream: false, - }; - if (config.provider === AGNES_PROVIDER) { - return { ...payload, max_tokens: maxCompletionTokens }; - } - return { - ...payload, - response_format: { type: 'json_object' }, - max_completion_tokens: maxCompletionTokens, - enable_thinking: false, - }; -} - -async function requestAiJson(config, messages, maxCompletionTokens, context = {}) { - const maxAttempts = boundedInteger(config.requestRetryCount, DEFAULT_AI_REQUEST_RETRY_COUNT, 0, 4) + 1; - const requestStartedAt = Date.now(); - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const attemptStartedAt = Date.now(); - try { - const response = await axios.post(`${config.baseUrl}/chat/completions`, buildAiRequestPayload( - config, - messages, - maxCompletionTokens - ), { - headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }, - timeout: config.timeoutMs, - maxBodyLength: 20 * 1024 * 1024, - }); - const rawResponse = response.data || {}; - const content = rawResponse?.choices?.[0]?.message?.content || ''; - let parsed; - try { - parsed = parseModelJson(content); - } catch (parseError) { - parseError.code = 'AI_RESPONSE_INVALID'; - throw parseError; - } - return { - rawResponse, - parsed, - usage: normalizeUsage(rawResponse.usage), - request: { attempts: attempt, durationMs: Date.now() - requestStartedAt }, - }; - } catch (error) { - const status = Number(error?.response?.status || 0) || null; - const code = normalizeText(error?.response?.data?.error?.code || error?.code || 'AI_REQUEST_FAILED', 100); - const detail = getAiErrorDetail(error); - const retryable = isRetryableAiError(error); - const diagnostic = { - operation: normalizeText(context.operation, 40) || 'analysis', - batchIndex: Number(context.batchIndex) || null, - batchTotal: Number(context.batchTotal) || null, - attempt, - maxAttempts, - status, - code, - durationMs: Date.now() - attemptStartedAt, - detail, - }; - - if (retryable && attempt < maxAttempts) { - const delayMs = getAiRetryDelayMs(error, attempt, config.retryBaseDelayMs); - console.warn('[CsQuality] AI 请求暂时失败,准备重试:', { ...diagnostic, delayMs }); - if (typeof context.onRetry === 'function') { - try { - await context.onRetry({ - attempt, - nextAttempt: attempt + 1, - maxAttempts, - delayMs, - code, - status, - }); - } catch (progressError) { - console.warn('[CsQuality] 更新 AI 重试进度失败:', progressError.message); - } - } - await sleep(delayMs); - continue; - } - - console.error('[CsQuality] AI 服务请求失败:', diagnostic); - const timedOut = ['ETIMEDOUT', 'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT'].includes(code.toUpperCase()); - const authorizationFailed = [401, 403].includes(status); - const rateLimited = status === 429; - const wrapped = new Error( - authorizationFailed - ? 'AI质检服务配置校验失败,请联系管理员' - : rateLimited - ? 'AI分析任务较多,请稍后重试' - : timedOut - ? 'AI分析请求超时,请稍后重试' - : 'AI分析请求失败,请稍后重试' - ); - wrapped.statusCode = status || (timedOut ? 504 : 502); - wrapped.code = code; - wrapped.internalDetail = detail; - wrapped.attempts = attempt; - wrapped.requestDurationMs = Date.now() - requestStartedAt; - wrapped.aiStatus = status; - wrapped.operation = diagnostic.operation; - throw wrapped; - } - } - - throw new Error('AI分析请求失败,请稍后重试'); -} - -async function mapWithConcurrency(items, concurrency, mapper) { - const results = new Array(items.length); - let nextIndex = 0; - let firstError = null; - const worker = async () => { - while (nextIndex < items.length && !firstError) { - const index = nextIndex; - nextIndex += 1; - try { - results[index] = await mapper(items[index], index); - } catch (error) { - firstError ||= error; - } - } - }; - await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); - if (firstError) throw firstError; - return results; -} - -async function insertHistory(client, payload) { - const rows = await client.query( - `INSERT INTO ${HISTORY_TABLE} ( - analysis_id, status, goods_id, goods_name, shop_name, product_image_url, - chat_start_date, chat_end_date, source_message_count, source_customer_count, - product_metrics_json, request_prompt, ai_provider, ai_model, conversation_type, - progress_stage, progress_current, progress_total, progress_percent, progress_message, progress_updated_at, - created_by_user_id, created_by_name - ) OUTPUT INSERTED.id - VALUES (@analysisId, @status, @goodsId, @goodsName, @shopName, @productImageUrl, - @chatStartDate, @chatEndDate, @messageCount, @customerCount, - @metricsJson, @prompt, @provider, @model, @conversationType, - @progressStage, @progressCurrent, @progressTotal, @progressPercent, @progressMessage, SYSUTCDATETIME(), - @createdByUserId, @createdByName)`, - payload, - { requestTimeout: 120000 } - ); - return rows[0]?.id; -} - -async function updateHistoryContext(client, id, payload) { - await client.query( - `UPDATE ${HISTORY_TABLE} - SET goods_name = @goodsName, shop_name = @shopName, product_image_url = @productImageUrl, - source_message_count = @messageCount, source_customer_count = @customerCount, - product_metrics_json = @metricsJson, request_prompt = @prompt, - ai_provider = @provider, ai_model = @model, updated_at = SYSUTCDATETIME() - WHERE id = @id`, - { id, ...payload }, - { requestTimeout: 120000 } - ); -} - -async function updateHistoryProgress(client, id, payload) { - await client.query( - `UPDATE ${HISTORY_TABLE} - SET status = @status, progress_stage = @progressStage, - progress_current = @progressCurrent, progress_total = @progressTotal, - progress_percent = @progressPercent, progress_message = @progressMessage, - progress_updated_at = SYSUTCDATETIME(), updated_at = SYSUTCDATETIME() - WHERE id = @id`, - { id, ...payload }, - { requestTimeout: 120000 } - ); -} - -async function updateHistory(client, id, payload) { - await client.query( - `UPDATE ${HISTORY_TABLE} - SET status = @status, ai_response_json = @aiResponseJson, ai_raw_response_json = @aiRawResponseJson, - conversation_type = @conversationType, analysis_json = @analysisJson, error_message = @errorMessage, - duration_ms = @durationMs, prompt_tokens = @promptTokens, completion_tokens = @completionTokens, - total_tokens = @totalTokens, progress_stage = @progressStage, - progress_current = @progressCurrent, progress_total = @progressTotal, - progress_percent = @progressPercent, progress_message = @progressMessage, - progress_updated_at = SYSUTCDATETIME(), updated_at = SYSUTCDATETIME() - WHERE id = @id`, - { id, ...payload }, - { requestTimeout: 120000 } - ); -} - -function sanitizePublicError(value) { - return normalizeText(value, 2000) - .replace(/千问(?:模型)?/gi, 'AI服务') - .replace(/qwen[\w.:-]*/gi, 'AI服务') - .replace(/aliyun-model-studio/gi, 'AI服务') - .replace(/dashscope/gi, 'AI服务') - .replace(/(?:api|apihub)\.agnes-ai\.(?:cn|com)/gi, 'AI服务') - .replace(/agnes(?:-ai)?[\w.:-]*/gi, 'AI服务'); -} - -function createProgressReporter(client, historyId) { - let lastPercent = 0; - let writeQueue = Promise.resolve(); - return (progress = {}) => { - lastPercent = Math.max(lastPercent, boundedInteger(progress.percent, lastPercent, 0, 100)); - const payload = { - status: progress.status || 'running', - progressStage: normalizeText(progress.stage, 40) || 'running', - progressCurrent: Math.max(0, Number(progress.current) || 0), - progressTotal: Math.max(0, Number(progress.total) || 0), - progressPercent: lastPercent, - progressMessage: normalizeText(progress.message, 500) || '正在处理聊天记录', - }; - writeQueue = writeQueue.then(() => updateHistoryProgress(client, historyId, payload)); - return writeQueue; - }; -} - -async function createQueuedAnalysisHistory(input = {}, actor = {}) { - const goodsId = normalizeGoodsId(input.goodsId); - const range = resolveDateRange(input.startDate, input.endDate); - await ensureHistorySchema(); - const client = await getHistoryClient(); - const analysisId = crypto.randomUUID(); - const historyId = await insertHistory(client, { - analysisId, status: 'queued', goodsId, goodsName: normalizeText(input.goodsName, 500) || null, - shopName: normalizeText(input.shopName, 255) || null, - productImageUrl: normalizeText(input.hdThumbUrl, 2000) || null, - chatStartDate: range.startDate, chatEndDate: range.endDate, - messageCount: 0, customerCount: 0, metricsJson: null, - prompt: null, provider: null, model: null, conversationType: null, - progressStage: 'queued', progressCurrent: 0, progressTotal: 0, progressPercent: 0, - progressMessage: '质检任务已创建,正在等待处理', - createdByUserId: normalizeText(actor.sub || actor.id, 100) || null, createdByName: normalizeText(actor.name || actor.username, 100) || null, - }); - if (!historyId) throw new Error('创建质检任务失败,请稍后重试'); - return { client, historyId, analysisId, goodsId, range, startedAt: Date.now() }; -} - -async function startAnalysisJob(input = {}, actor = {}) { - const queued = await createQueuedAnalysisHistory(input, actor); - const normalizedInput = { - ...input, - goodsId: queued.goodsId, - startDate: queued.range.startDate, - endDate: queued.range.endDate, - }; - setImmediate(() => { - runAnalysis(normalizedInput, actor, queued).catch((error) => { - console.error('[CsQuality] 后台质检任务失败:', { - analysisId: queued.analysisId, - message: error.message, - detail: error.internalDetail || null, - }); - }); - }); - return { - historyId: queued.historyId, - analysisId: queued.analysisId, - status: 'queued', - range: queued.range, - createdAt: new Date(queued.startedAt).toISOString(), - progress: { - stage: 'queued', current: 0, total: 0, percent: 0, - message: '质检任务已创建,正在等待处理', - }, - }; -} - -async function runAnalysis(input = {}, actor = {}, runtime = {}) { - const state = runtime.historyId - ? { - client: runtime.client || await getHistoryClient(), - historyId: runtime.historyId, - analysisId: runtime.analysisId, - goodsId: normalizeGoodsId(input.goodsId), - range: resolveDateRange(input.startDate, input.endDate), - startedAt: runtime.startedAt || Date.now(), - } - : await createQueuedAnalysisHistory(input, actor); - const { client, historyId, analysisId, goodsId, range, startedAt } = state; - const reportProgress = createProgressReporter(client, historyId); - let progressTotal = 0; - let progressCurrent = 0; - - try { - await reportProgress({ stage: 'preparing', percent: 3, message: '正在核对商品与时间范围' }); - const [profile, config] = await Promise.all([ - getProductProfile({ goodsId, startDate: range.startDate, endDate: range.endDate }), - getAiConfig(), - ]); - const sourceCounts = { - messageCount: Number(profile.chatSummary?.messageCount || 0), - customerCount: Number(profile.chatSummary?.userCount || 0), - }; - if (!sourceCounts.messageCount) throw new Error('所选日期内没有该商品的聊天记录'); - if (sourceCounts.messageCount > MAX_ANALYSIS_MESSAGES) { - throw new Error(`聊天记录共${sourceCounts.messageCount}条,超过单次分析上限${MAX_ANALYSIS_MESSAGES}条,请缩短日期范围`); - } - await reportProgress({ - stage: 'loading', percent: 10, - message: `已统计 ${sourceCounts.customerCount} 个客户、${sourceCounts.messageCount} 条消息,正在读取聊天内容`, - }); - - const messageResult = await getMessages({ goodsId, startDate: range.startDate, endDate: range.endDate }); - const chatText = buildChatText(messageResult.rows); - if (chatText.length > MAX_ANALYSIS_TEXT_LENGTH) throw new Error('聊天内容过长,请缩短日期范围后再分析'); - await reportProgress({ stage: 'grouping', percent: 16, message: '正在按客户整理完整会话,避免上下文被截断' }); - - const product = { - ...profile.product, - goodsId, - goodsName: profile.product.goodsName || input.goodsName || '', - shopName: profile.product.shopName || input.shopName || '', - }; - const batches = createChatBatches(messageResult.rows, config.batchMaxChars); - if (!batches.length) throw new Error('所选日期内没有可分析的客户会话'); - progressTotal = batches.length; - const requestMetadata = JSON.stringify({ - mode: 'segmented-conversations', - provider: config.provider, - model: config.model, - batchMaxChars: config.batchMaxChars, - batchConcurrency: config.batchConcurrency, - batches: batches.map((batch) => ({ - index: batch.index, - messageCount: batch.messageCount, - customerCount: batch.customerCount, - charCount: batch.charCount, - conversationIds: batch.conversationIds, - oversizedConversation: batch.oversizedConversation, - startDate: batch.startDate, - endDate: batch.endDate, - })), - }); - await updateHistoryContext(client, historyId, { - goodsName: product.goodsName, - shopName: product.shopName, - productImageUrl: product.hdThumbUrl || input.hdThumbUrl || null, - messageCount: sourceCounts.messageCount, - customerCount: sourceCounts.customerCount, - metricsJson: JSON.stringify(profile.metrics), - prompt: requestMetadata, - provider: config.provider, - model: config.model, - }); - await reportProgress({ - stage: 'batching', total: progressTotal, percent: 20, - message: `已拆分为 ${progressTotal} 批,准备开始分析`, - }); - - const batchResponses = await mapWithConcurrency(batches, config.batchConcurrency, async (batch) => { - await reportProgress({ - stage: 'analyzing', current: progressCurrent, total: progressTotal, - percent: 20 + Math.floor((progressCurrent / progressTotal) * 64), - message: `正在分析 ${formatBatchDateRange(batch)} 的聊天记录(第 ${batch.index}/${progressTotal} 批)`, - }); - const prompt = buildUserPrompt({ - product, - metrics: profile.metrics, - chatText: batch.text, - startDate: range.startDate, - endDate: range.endDate, - }); - let response; - try { - response = await requestAiJson(config, [ - { role: 'system', content: buildSystemPrompt() }, - { - role: 'user', - content: `${prompt}\n\n这是分批质检中的第 ${batch.index}/${batches.length} 批,仅分析本批提供的完整客户会话。`, - }, - ], config.batchMaxTokens, { - operation: 'batch-analysis', - batchIndex: batch.index, - batchTotal: progressTotal, - onRetry: ({ nextAttempt, maxAttempts, delayMs }) => reportProgress({ - stage: 'retrying', current: progressCurrent, total: progressTotal, - percent: 20 + Math.floor((progressCurrent / progressTotal) * 64), - message: `第 ${batch.index}/${progressTotal} 批连接波动,将在 ${Math.max(1, Math.ceil(delayMs / 1000))} 秒后进行第 ${nextAttempt}/${maxAttempts} 次尝试`, - }), - }); - } catch (error) { - error.failedBatch = { - index: batch.index, - total: progressTotal, - messageCount: batch.messageCount, - customerCount: batch.customerCount, - charCount: batch.charCount, - startDate: batch.startDate, - endDate: batch.endDate, - }; - throw error; - } - const batchResponse = { - batch: { - index: batch.index, - messageCount: batch.messageCount, - customerCount: batch.customerCount, - charCount: batch.charCount, - conversationIds: batch.conversationIds, - startDate: batch.startDate, - endDate: batch.endDate, - }, - analysis: normalizeAnalysis(response.parsed, { - messageCount: batch.messageCount, - customerCount: batch.customerCount, - }, { - allowedConversationIds: batch.conversationIds, - }), - rawResponse: response.rawResponse, - usage: response.usage, - }; - progressCurrent += 1; - await reportProgress({ - stage: 'analyzing', current: progressCurrent, total: progressTotal, - percent: 20 + Math.floor((progressCurrent / progressTotal) * 64), - message: progressCurrent < progressTotal - ? `已完成 ${progressCurrent}/${progressTotal} 批,正在继续处理其余聊天记录` - : `已完成全部 ${progressTotal} 批聊天记录,正在整理诊断结果`, - }); - return batchResponse; - }); - - await reportProgress({ - stage: 'merging', current: progressTotal, total: progressTotal, percent: 88, - message: '批次分析完成,正在合并诊断结果', - }); - const localAnalysis = mergeBatchAnalysesLocally( - batchResponses.map((response) => response.analysis), - sourceCounts - ); - let normalized = localAnalysis; - let mergeResponse = null; - let mergeFallback = false; - let mergeError = null; - const mergeSkipped = batchResponses.length > 1 && ( - config.mergeMaxBatches === 0 || batchResponses.length > config.mergeMaxBatches - ); - if (batchResponses.length > 1 && !mergeSkipped) { - try { - mergeResponse = await requestAiJson(config, [ - { role: 'system', content: buildSystemPrompt() }, - { role: 'user', content: buildMergePrompt({ product, sourceCounts, localAnalysis }) }, - ], config.mergeMaxTokens, { - operation: 'merge-analysis', - onRetry: ({ nextAttempt, maxAttempts, delayMs }) => reportProgress({ - stage: 'retrying', current: progressTotal, total: progressTotal, percent: 88, - message: `汇总连接波动,将在 ${Math.max(1, Math.ceil(delayMs / 1000))} 秒后进行第 ${nextAttempt}/${maxAttempts} 次尝试`, - }), - }); - const modelAnalysis = normalizeAnalysis(mergeResponse.parsed, sourceCounts, { - allowedConversationIds: batches.flatMap((batch) => batch.conversationIds), - }); - normalized = reconcileMergedAnalysis(modelAnalysis, localAnalysis, sourceCounts); - } catch (error) { - mergeFallback = true; - mergeError = normalizeText(error.internalDetail || error.message, 1000); - console.warn('[CsQuality] 智能汇总请求失败,使用已完成批次生成结果:', mergeError); - } - } - - await reportProgress({ - stage: 'validating', current: progressTotal, total: progressTotal, percent: 94, - message: mergeFallback - ? '智能汇总耗时较长,正在使用已完成批次校验结果' - : '正在校验问题分类和影响客户数', - }); - - const usage = sumUsages([ - ...batchResponses.map((response) => response.usage), - ...(mergeResponse ? [mergeResponse.usage] : []), - ]); - const rawResponse = { - provider: config.provider, - model: config.model, - batching: { - batchCount: batches.length, - batchMaxChars: config.batchMaxChars, - concurrency: config.batchConcurrency, - mergeFallback, - mergeSkipped, - mergeError, - }, - batches: batchResponses.map((response) => ({ - batch: response.batch, - response: response.rawResponse, - })), - mergeResponse: mergeResponse?.rawResponse || null, - }; - await reportProgress({ - stage: 'saving', current: progressTotal, total: progressTotal, percent: 98, - message: '正在保存质检结果', - }); - await updateHistory(client, historyId, { - status: 'succeeded', aiResponseJson: JSON.stringify(normalized), aiRawResponseJson: JSON.stringify(rawResponse), - conversationType: normalized.conversation_type, analysisJson: JSON.stringify(normalized), errorMessage: null, - durationMs: Date.now() - startedAt, promptTokens: usage.prompt_tokens || null, - completionTokens: usage.completion_tokens || null, totalTokens: usage.total_tokens || null, - progressStage: 'completed', progressCurrent: progressTotal, progressTotal, - progressPercent: 100, progressMessage: '质检完成', - }); - return { - historyId, - analysisId, - range, - product, - metrics: profile.metrics, - chatSummary: profile.chatSummary, - sourceCounts, - ai: { - usage, - durationMs: Date.now() - startedAt, - batching: { - batchCount: batches.length, - batchMaxChars: config.batchMaxChars, - concurrency: config.batchConcurrency, - mergeFallback, - mergeSkipped, - }, - }, - analysis: normalized, - }; - } catch (error) { - const publicError = sanitizePublicError(error.message) || 'AI质检失败,请稍后重试'; - const failureDiagnostic = { - type: 'cs-quality-failure', - analysisId, - operation: normalizeText(error.operation, 40) || null, - code: normalizeText(error.code, 100) || null, - status: Number(error.aiStatus || error.statusCode || 0) || null, - attempts: Number(error.attempts || 0) || null, - requestDurationMs: Number(error.requestDurationMs || 0) || null, - detail: getAiErrorDetail({ message: error.internalDetail || error.message, code: error.code }), - failedBatch: error.failedBatch || null, - }; - console.error('[CsQuality] 质检任务执行失败:', failureDiagnostic); - await updateHistory(client, historyId, { - status: 'failed', aiResponseJson: null, aiRawResponseJson: JSON.stringify(failureDiagnostic), conversationType: null, analysisJson: null, - errorMessage: publicError, - durationMs: Date.now() - startedAt, promptTokens: null, completionTokens: null, totalTokens: null, - progressStage: 'failed', progressCurrent, progressTotal, - progressPercent: 100, progressMessage: '质检未完成,请查看失败原因后重试', - }).catch((updateError) => console.error('[CsQuality] 保存 AI 失败记录失败:', updateError.message)); - error.message = publicError; - throw error; - } -} - -function sqlDateToText(value) { - if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString().slice(0, 10); - const match = String(value || '').match(/\d{4}-\d{2}-\d{2}/); - return match?.[0] || null; -} - -function serializeAnalysisJob(row) { - const status = normalizeText(row.status, 20) || 'failed'; - const analysis = parseJson(row.analysisJson, null) || parseJson(row.aiResponseJson, null); - const metrics = parseJson(row.metricsJson, {}); - const requestMetadata = parseJson(row.requestPrompt, {}); - const messageCount = Number(row.messageCount || 0); - const customerCount = Number(row.customerCount || 0); - const range = { - startDate: sqlDateToText(row.chatStartDate), - endDate: sqlDateToText(row.chatEndDate), - }; - const product = { - goodsId: row.goodsId, - goodsName: row.goodsName, - shopName: row.shopName, - hdThumbUrl: row.productImageUrl, - }; - const progress = { - stage: normalizeText(row.progressStage, 40) || status, - current: Math.max(0, Number(row.progressCurrent || 0)), - total: Math.max(0, Number(row.progressTotal || 0)), - percent: status === 'succeeded' ? 100 : Math.min(100, Math.max(0, Number(row.progressPercent || 0))), - message: sanitizePublicError(row.progressMessage) - || (status === 'succeeded' ? '质检完成' : status === 'failed' ? '质检未完成' : '正在处理聊天记录'), - updatedAt: row.progressUpdatedAt || row.updatedAt || null, - }; - const job = { - historyId: row.id, - analysisId: row.analysisId, - status, - range, - product, - messageCount, - customerCount, - durationMs: Number(row.durationMs || 0) || null, - errorMessage: sanitizePublicError(row.errorMessage), - createdAt: row.createdAt, - updatedAt: row.updatedAt, - progress, - }; - if (status === 'succeeded' && analysis) { - job.result = { - historyId: row.id, - analysisId: row.analysisId, - range, - product, - metrics, - chatSummary: { messageCount, userCount: customerCount }, - sourceCounts: { messageCount, customerCount }, - ai: { - durationMs: Number(row.durationMs || 0), - usage: { - prompt_tokens: Number(row.promptTokens || 0), - completion_tokens: Number(row.completionTokens || 0), - total_tokens: Number(row.totalTokens || 0), - }, - batching: { batchCount: Array.isArray(requestMetadata.batches) ? requestMetadata.batches.length : progress.total }, - }, - analysis, - }; - } - return job; -} - -async function getAnalysisJob(analysisId) { - const normalizedId = normalizeText(analysisId, 64); - if (!normalizedId) { - const error = new Error('质检任务编号不能为空'); - error.statusCode = 400; - throw error; - } - await ensureHistorySchema(); - const client = await getHistoryClient(); - const rows = await client.query( - `SELECT TOP 1 - id, analysis_id AS [analysisId], status, - goods_id AS [goodsId], goods_name AS [goodsName], shop_name AS [shopName], product_image_url AS [productImageUrl], - chat_start_date AS [chatStartDate], chat_end_date AS [chatEndDate], - source_message_count AS [messageCount], source_customer_count AS [customerCount], - product_metrics_json AS [metricsJson], request_prompt AS [requestPrompt], - ai_response_json AS [aiResponseJson], analysis_json AS [analysisJson], error_message AS [errorMessage], - duration_ms AS [durationMs], prompt_tokens AS [promptTokens], completion_tokens AS [completionTokens], total_tokens AS [totalTokens], - progress_stage AS [progressStage], progress_current AS [progressCurrent], progress_total AS [progressTotal], - progress_percent AS [progressPercent], progress_message AS [progressMessage], progress_updated_at AS [progressUpdatedAt], - created_at AS [createdAt], updated_at AS [updatedAt] - FROM ${HISTORY_TABLE} WHERE analysis_id = @analysisId`, - { analysisId: normalizedId }, - { requestTimeout: 120000 } - ); - if (!rows[0]) { - const error = new Error('质检任务不存在'); - error.statusCode = 404; - throw error; - } - const row = rows[0]; - const lastProgressAt = Date.parse(row.progressUpdatedAt || row.updatedAt || row.createdAt || ''); - if (['queued', 'running'].includes(row.status) - && Number.isFinite(lastProgressAt) - && Date.now() - lastProgressAt > CS_QUALITY_JOB_STALE_MS) { - const errorMessage = '质检任务长时间没有更新,可能因服务重启而中断,请重新发起'; - await client.query( - `UPDATE ${HISTORY_TABLE} - SET status = N'failed', error_message = @errorMessage, - progress_stage = N'failed', progress_percent = 100, progress_message = @progressMessage, - duration_ms = COALESCE(duration_ms, DATEDIFF_BIG(MILLISECOND, created_at, SYSUTCDATETIME())), - progress_updated_at = SYSUTCDATETIME(), updated_at = SYSUTCDATETIME() - WHERE id = @id AND status IN (N'queued', N'running')`, - { id: row.id, errorMessage, progressMessage: '质检任务已中断,请重新发起' }, - { requestTimeout: 120000 } - ); - row.status = 'failed'; - row.errorMessage = errorMessage; - row.progressStage = 'failed'; - row.progressPercent = 100; - row.progressMessage = '质检任务已中断,请重新发起'; - } - return serializeAnalysisJob(row); -} - -async function listHistory(options = {}) { - await ensureHistorySchema(); - const client = await getHistoryClient(); - const page = Math.max(1, Number(options.page) || 1); - const pageSize = Math.min(100, Math.max(1, Number(options.pageSize) || 20)); - const offset = (page - 1) * pageSize; - const params = { offset, pageSize }; - const conditions = ['1=1']; - if (normalizeText(options.goodsId, 80)) { params.goodsId = `%${normalizeText(options.goodsId, 80)}%`; conditions.push('h.goods_id LIKE @goodsId'); } - if (normalizeText(options.shopName, 200)) { params.shopName = `%${normalizeText(options.shopName, 200)}%`; conditions.push('h.shop_name LIKE @shopName'); } - const [countRows, rows] = await Promise.all([ - client.query( - `SELECT COUNT_BIG(1) AS [total] FROM ${HISTORY_TABLE} h WHERE ${conditions.join(' AND ')}`, - params, - { requestTimeout: 120000 } - ), - client.query( - `SELECT id, analysis_id AS [analysisId], status, goods_id AS [goodsId], goods_name AS [goodsName], shop_name AS [shopName], product_image_url AS [productImageUrl], - chat_start_date AS [chatStartDate], chat_end_date AS [chatEndDate], source_message_count AS [messageCount], source_customer_count AS [customerCount], - conversation_type AS [conversationType], error_message AS [errorMessage], duration_ms AS [durationMs], - progress_current AS [progressCurrent], progress_total AS [progressTotal], progress_percent AS [progressPercent], progress_message AS [progressMessage], - created_by_name AS [createdByName], created_at AS [createdAt] - FROM ${HISTORY_TABLE} h WHERE ${conditions.join(' AND ')} ORDER BY h.created_at DESC OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`, - params, - { requestTimeout: 120000 } - ), - ]); - return { - page, - pageSize, - total: Number(countRows?.[0]?.total || 0), - rows: (rows || []).map((row) => ({ - ...row, - errorMessage: sanitizePublicError(row.errorMessage), - progressMessage: sanitizePublicError(row.progressMessage), - })), - }; -} - -async function getHistoryDetail(id) { - await ensureHistorySchema(); - const client = await getHistoryClient(); - const rows = await client.query( - `SELECT TOP 1 - id, analysis_id, status, goods_id, goods_name, shop_name, product_image_url, - chat_start_date, chat_end_date, source_message_count, source_customer_count, - product_metrics_json, ai_response_json, conversation_type, analysis_json, error_message, - duration_ms, prompt_tokens, completion_tokens, total_tokens, - progress_stage, progress_current, progress_total, progress_percent, progress_message, progress_updated_at, - created_by_user_id, created_by_name, created_at, updated_at - FROM ${HISTORY_TABLE} WHERE id = @id`, - { id: Number(id) }, - { requestTimeout: 120000 } - ); - if (!rows[0]) return null; - const row = rows[0]; - return { - ...row, - error_message: sanitizePublicError(row.error_message), - progress_message: sanitizePublicError(row.progress_message), - analysis: parseJson(row.analysis_json, null), - metrics: parseJson(row.product_metrics_json, {}), - aiResponse: parseJson(row.ai_response_json, null), - }; -} - -module.exports = { - QWEN_PROVIDER, - AGNES_PROVIDER, - DEFAULT_QWEN_BASE_URL, - DEFAULT_QWEN_MODEL, - DEFAULT_AGNES_BASE_URL, - DEFAULT_AGNES_MODEL, - getGoods, - getChatDataMeta, - getCustomers, - getMessages, - getProductProfile, - startAnalysisJob, - getAnalysisJob, - runAnalysis, - listHistory, - getHistoryDetail, - _test: { - resolveAiConfig, - buildAiRequestPayload, - parseModelJson, - mapChatDataMeta, - aggregateChatDimensionRows, - buildConversationGroups, - createChatBatches, - normalizeAnalysis, - mergeBatchAnalysesLocally, - reconcileMergedAnalysis, - normalizeUsage, - sumUsages, - getAiErrorDetail, - isRetryableAiError, - getAiRetryDelayMs, - sanitizePublicError, - }, -}; diff --git a/backend/src/services/desktopReleaseService.js b/backend/src/services/desktopReleaseService.js deleted file mode 100644 index e6a2ff4..0000000 --- a/backend/src/services/desktopReleaseService.js +++ /dev/null @@ -1,254 +0,0 @@ -const database = require('../config/database'); - -const DESKTOP_RELEASE_CONFIG_KEY = 'desktop_client_releases'; -const MAX_RELEASE_HISTORY = 50; -const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; -const SUPPORTED_PLATFORMS = new Set(['win32', 'darwin']); -const SUPPORTED_ARCHITECTURES = new Set(['x64', 'arm64', 'universal']); - -function normalizeVersion(value) { - return String(value || '').trim().replace(/^v/i, ''); -} - -function versionParts(value) { - const normalized = normalizeVersion(value); - if (!VERSION_PATTERN.test(normalized)) return null; - return normalized.split('.').map((part) => Number(part)); -} - -function compareVersions(left, right) { - const leftParts = versionParts(left); - const rightParts = versionParts(right); - if (!leftParts || !rightParts) return 0; - for (let index = 0; index < 3; index += 1) { - if (leftParts[index] !== rightParts[index]) { - return leftParts[index] > rightParts[index] ? 1 : -1; - } - } - return 0; -} - -function validateVersion(value) { - const version = normalizeVersion(value); - if (!VERSION_PATTERN.test(version)) { - throw new Error('版本号格式必须为 x.y.z,例如 0.0.9'); - } - return version; -} - -function normalizePlatform(value, fileName = '') { - const normalized = String(value || '').trim().toLowerCase(); - if (['darwin', 'mac', 'macos', 'osx'].includes(normalized)) return 'darwin'; - if (['win32', 'win', 'windows'].includes(normalized)) return 'win32'; - if (String(fileName || '').toLowerCase().endsWith('.dmg')) return 'darwin'; - return 'win32'; -} - -function normalizeArchitecture(value, fileName = '', platform = '') { - const normalized = String(value || '').trim().toLowerCase(); - if (SUPPORTED_ARCHITECTURES.has(normalized)) return normalized; - const fileMatch = String(fileName || '').toLowerCase().match(/[-_.](arm64|x64|universal)(?=[-_.]|$)/); - if (fileMatch) return fileMatch[1]; - return normalizePlatform(platform, fileName) === 'darwin' ? 'arm64' : 'x64'; -} - -function normalizeStoredRelease(value) { - if (!value || typeof value !== 'object') return null; - const version = normalizeVersion(value.version); - if (!VERSION_PATTERN.test(version) || !value.objectKey) return null; - const platform = normalizePlatform(value.platform, value.fileName || value.objectKey); - const architecture = normalizeArchitecture(value.architecture, value.fileName || value.objectKey, platform); - const defaultFileName = platform === 'darwin' - ? `pdd-auto-sale-${version}-${architecture}.dmg` - : `pdd-auto-sale-${version}-setup.exe`; - return { - id: Number(value.id) || Date.now(), - version, - releaseNotes: String(value.releaseNotes || ''), - objectKey: String(value.objectKey), - platform, - architecture, - fileName: String(value.fileName || defaultFileName), - fileSize: Number(value.fileSize || 0), - sha256: String(value.sha256 || ''), - publishedByUserId: value.publishedByUserId == null ? null : Number(value.publishedByUserId), - publishedByName: String(value.publishedByName || ''), - publishedAt: value.publishedAt || value.createdAt || null, - createdAt: value.createdAt || value.publishedAt || null, - updatedAt: value.updatedAt || value.publishedAt || null, - }; -} - -function parseReleaseStorage(value) { - try { - const parsed = typeof value === 'string' ? JSON.parse(value || '{}') : value; - const releases = Array.isArray(parsed?.releases) - ? parsed.releases.map(normalizeStoredRelease).filter(Boolean) - : []; - const storedLatestVersions = parsed?.latestVersions && typeof parsed.latestVersions === 'object' - ? parsed.latestVersions - : {}; - const latestVersions = {}; - for (const platform of SUPPORTED_PLATFORMS) { - const version = normalizeVersion(storedLatestVersions[platform]); - if (VERSION_PATTERN.test(version)) latestVersions[platform] = version; - } - const legacyLatestVersion = normalizeVersion(parsed?.latestVersion); - if (!latestVersions.win32 && VERSION_PATTERN.test(legacyLatestVersion)) { - latestVersions.win32 = legacyLatestVersion; - } - for (const release of releases) { - if (!latestVersions[release.platform]) latestVersions[release.platform] = release.version; - } - return { - latestVersion: latestVersions.win32 || legacyLatestVersion, - latestVersions, - releases, - }; - } catch { - return { latestVersion: '', latestVersions: {}, releases: [] }; - } -} - -async function loadReleaseStorage() { - const rows = await database.local.query( - 'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1', - [DESKTOP_RELEASE_CONFIG_KEY] - ); - return parseReleaseStorage(rows[0]?.config_value); -} - -async function saveReleaseStorage(storage) { - const value = JSON.stringify(storage); - const existing = await database.local.query( - 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', - [DESKTOP_RELEASE_CONFIG_KEY] - ); - if (existing.length) { - await database.local.query( - 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', - [value, 'json', DESKTOP_RELEASE_CONFIG_KEY] - ); - return; - } - await database.local.insert('system_config', { - config_key: DESKTOP_RELEASE_CONFIG_KEY, - config_value: value, - config_type: 'json', - }); -} - -async function ensureDesktopReleaseSchema() { - // Release metadata uses system_config because the runtime account cannot create tables. -} - -function mapRelease(release, latestVersion = '') { - const normalized = normalizeStoredRelease(release); - if (!normalized) return null; - return { - ...normalized, - isLatest: normalized.version === normalizeVersion(latestVersion), - }; -} - -async function getLatestDesktopRelease(platform = 'win32', architecture = '') { - const storage = await loadReleaseStorage(); - const targetPlatform = normalizePlatform(platform); - const targetArchitecture = architecture - ? normalizeArchitecture(architecture, '', targetPlatform) - : ''; - const platformReleases = storage.releases - .filter((release) => release.platform === targetPlatform) - .sort((left, right) => ( - compareVersions(right.version, left.version) - || String(right.publishedAt || '').localeCompare(String(left.publishedAt || '')) - )); - const latestVersion = storage.latestVersions[targetPlatform] || platformReleases[0]?.version || ''; - const versionReleases = platformReleases.filter((release) => release.version === latestVersion); - const latest = (targetArchitecture - ? versionReleases.find((release) => release.architecture === targetArchitecture) - || versionReleases.find((release) => release.architecture === 'universal') - : null) - || versionReleases[0] - || platformReleases[0] - || null; - return latest ? mapRelease(latest, latestVersion || latest.version) : null; -} - -async function getDesktopReleaseById(id) { - const storage = await loadReleaseStorage(); - const release = storage.releases.find((item) => String(item.id) === String(id)); - return release ? mapRelease(release, storage.latestVersions[release.platform]) : null; -} - -async function listDesktopReleases(limit = 50) { - const storage = await loadReleaseStorage(); - const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 100); - return storage.releases - .map((release) => mapRelease(release, storage.latestVersions[release.platform])) - .filter(Boolean) - .sort((left, right) => { - if (left.isLatest !== right.isLatest) return left.isLatest ? -1 : 1; - return String(right.publishedAt || '').localeCompare(String(left.publishedAt || '')); - }) - .slice(0, safeLimit); -} - -async function publishDesktopRelease(input) { - const version = validateVersion(input.version); - const storage = await loadReleaseStorage(); - const platform = normalizePlatform(input.platform, input.fileName || input.objectKey); - const architecture = normalizeArchitecture(input.architecture, input.fileName || input.objectKey, platform); - const existing = storage.releases.find((release) => ( - release.version === version - && release.platform === platform - && release.architecture === architecture - )); - const now = new Date().toISOString(); - const release = { - id: existing?.id || Date.now(), - version, - platform, - architecture, - releaseNotes: input.releaseNotes || '', - objectKey: input.objectKey, - fileName: input.fileName, - fileSize: Number(input.fileSize || 0), - sha256: input.sha256, - publishedByUserId: input.publishedByUserId || null, - publishedByName: input.publishedByName || '', - publishedAt: now, - createdAt: existing?.createdAt || now, - updatedAt: now, - }; - const releases = [ - release, - ...storage.releases.filter((item) => !( - item.version === version - && item.platform === platform - && item.architecture === architecture - )), - ].slice(0, MAX_RELEASE_HISTORY); - const latestVersions = { ...storage.latestVersions, [platform]: version }; - await saveReleaseStorage({ - latestVersion: latestVersions.win32 || version, - latestVersions, - releases, - }); - return mapRelease(release, version); -} - -module.exports = { - compareVersions, - ensureDesktopReleaseSchema, - getDesktopReleaseById, - getLatestDesktopRelease, - listDesktopReleases, - mapRelease, - normalizeVersion, - normalizeArchitecture, - normalizePlatform, - parseReleaseStorage, - publishDesktopRelease, - validateVersion, -}; diff --git a/backend/src/services/desktopSessionService.js b/backend/src/services/desktopSessionService.js index d1d8768..6ce964c 100644 --- a/backend/src/services/desktopSessionService.js +++ b/backend/src/services/desktopSessionService.js @@ -66,7 +66,7 @@ function buildDesktopCapabilities(user) { const allowed = new Set(Array.isArray(user?.allowedPageKeys) ? user.allowedPageKeys : []); const isAdmin = user?.role === 'admin'; const canPlan = isAdmin || allowed.has('planning_upload'); - const canUseMarket = isAdmin || allowed.has('market_insight') || canPlan; + const canUseMarket = isAdmin || canPlan; return { controlCenter: true, @@ -80,7 +80,6 @@ function buildDesktopCapabilities(user) { marketingEvents: isAdmin || allowed.has('marketing_events'), promotion: isAdmin || allowed.has('promotion_module'), productTest: isAdmin || allowed.has('new_product_test'), - reviewAnalysis: isAdmin || allowed.has('review_analysis'), }; } diff --git a/backend/src/services/localSchema.js b/backend/src/services/localSchema.js index edce7a2..3e3d5fa 100644 --- a/backend/src/services/localSchema.js +++ b/backend/src/services/localSchema.js @@ -3,7 +3,6 @@ const { cleanupRetiredFeatureSettings } = require('./retiredFeatures'); const { ensureDepartmentPagePermissions, ensureSidebarRolePermissions } = require('./pageAccess'); const { repairDuplicateExternalDepartments } = require('./socialAuth'); const { ensureSetupConfigDefaults } = require('./setupWizard'); -const { ensureDesktopReleaseSchema } = require('./desktopReleaseService'); const { ensureDesktopPresenceSchema } = require('./desktopPresenceService'); const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService'); const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService'); @@ -119,6 +118,18 @@ async function ensureTable(tableName, createSql) { } } +async function tableExists(tableName) { + const rows = await database.local.query( + `SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [tableName] + ); + return rows.length > 0; +} + async function indexExists(tableName, indexName) { const rows = await database.local.query( `SELECT 1 @@ -279,7 +290,27 @@ async function ensureLocalSchema() { UNIQUE KEY uk_ops_b_period_scope (scope_start, scope_end) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4` ); - await ensureDesktopReleaseSchema(); + await ensureTable( + 'product_analysis_user_prefs', + `CREATE TABLE IF NOT EXISTS product_analysis_user_prefs ( + user_id BIGINT PRIMARY KEY COMMENT '用户ID', + prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化配置 JSON', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分析用户个性化配置'` + ); + if (await tableExists('pricing_calculator_user_prefs')) { + await database.local.query( + `INSERT INTO product_analysis_user_prefs (user_id, prefs_json, created_at, updated_at) + SELECT user_id, prefs_json, created_at, updated_at + FROM pricing_calculator_user_prefs + WHERE JSON_VALID(prefs_json) + AND JSON_EXTRACT(prefs_json, '$.productAnalysisSignalThresholds') IS NOT NULL + ON DUPLICATE KEY UPDATE + prefs_json = VALUES(prefs_json), + updated_at = VALUES(updated_at)` + ); + } await ensureDesktopPresenceSchema(); await ensureDesktopExecutionTaskSchema(); await ensureThirdPartySsoSchema(); diff --git a/backend/src/services/retiredFeatures.js b/backend/src/services/retiredFeatures.js index a2d0bf1..c9b130d 100644 --- a/backend/src/services/retiredFeatures.js +++ b/backend/src/services/retiredFeatures.js @@ -1,14 +1,31 @@ -const RETIRED_PAGE_KEYS = ['planning_upload', 'market_insight', 'agent', 'ai_skill_tags']; -const RETIRED_ENTRY_KEYS = new Set(['market-insight', 'agent', 'ai-skill-tags', 'platform-switch']); +const RETIRED_PAGE_KEYS = [ + 'planning_upload', 'market_insight', 'agent', 'ai_skill_tags', + 'store_checkup', 'review_analysis', 'customer_service_checkup', + 'pricing_calculator', 'desktop_client_download', 'ai_buyer_show', 'ai_seeding_video', +]; +const RETIRED_ENTRY_KEYS = new Set([ + 'market-insight', 'agent', 'ai-skill-tags', 'platform-switch', + 'store-management', 'store-checkup', 'review-analysis', 'customer-service-checkup', + 'operation-tools', 'pricing-calculator', 'desktop-client-download', 'ai-buyer-show', 'ai-seeding-video', + 'desktop-client-releases', +]); const RETIRED_CONFIG_KEYS = [ 'openclaw_chat_config', 'openclaw_chat_skill_tags', 'selection_similar_products_config', 'alibaba_1688_image_search_config', + 'cs_quality_ai_config', 'desktop_client_releases', + 'ai_buyer_show_ark_api_key', 'ai_buyer_show_ark_base_url', + 'ai_buyer_show_vision_model', 'ai_buyer_show_review_model', ]; +const RETIRED_DATABASE_CONFIG_NAMES = ['ai_buyer_show_database']; function isRetiredSystemConfigKey(key) { return RETIRED_CONFIG_KEYS.includes(String(key || '').trim()); } +function isRetiredDatabaseConfigName(name) { + return RETIRED_DATABASE_CONFIG_NAMES.includes(String(name || '').trim()); +} + function pruneRetiredSidebarConfig(value) { try { const config = typeof value === 'string' ? JSON.parse(value) : value; @@ -28,6 +45,9 @@ async function cleanupRetiredFeatureSettings(db) { await db.query(`DELETE FROM ${table} WHERE page_key IN (${RETIRED_PAGE_KEYS.map(() => '?').join(',')})`, RETIRED_PAGE_KEYS); } await db.query(`DELETE FROM system_config WHERE config_key IN (${RETIRED_CONFIG_KEYS.map(() => '?').join(',')})`, RETIRED_CONFIG_KEYS); + await db.query(`DELETE FROM database_configs WHERE name IN (${RETIRED_DATABASE_CONFIG_NAMES.map(() => '?').join(',')})`, RETIRED_DATABASE_CONFIG_NAMES); + await db.query('DROP TABLE IF EXISTS pricing_calculator_history'); + await db.query('DROP TABLE IF EXISTS pricing_calculator_user_prefs'); const rows = await db.query("SELECT config_value FROM system_config WHERE config_key = ?", ['sidebar_navigation']); if (rows.length) { const current = rows[0].config_value; @@ -36,4 +56,12 @@ async function cleanupRetiredFeatureSettings(db) { } } -module.exports = { RETIRED_PAGE_KEYS, RETIRED_CONFIG_KEYS, isRetiredSystemConfigKey, pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings }; +module.exports = { + RETIRED_PAGE_KEYS, + RETIRED_CONFIG_KEYS, + RETIRED_DATABASE_CONFIG_NAMES, + isRetiredSystemConfigKey, + isRetiredDatabaseConfigName, + pruneRetiredSidebarConfig, + cleanupRetiredFeatureSettings, +}; diff --git a/backend/test/csQualityService.test.js b/backend/test/csQualityService.test.js deleted file mode 100644 index 1f39590..0000000 --- a/backend/test/csQualityService.test.js +++ /dev/null @@ -1,255 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { - QWEN_PROVIDER, - AGNES_PROVIDER, - DEFAULT_QWEN_BASE_URL, - DEFAULT_QWEN_MODEL, - DEFAULT_AGNES_BASE_URL, - DEFAULT_AGNES_MODEL, - _test: { - resolveAiConfig, - buildAiRequestPayload, - parseModelJson, - mapChatDataMeta, - aggregateChatDimensionRows, - createChatBatches, - normalizeAnalysis, - mergeBatchAnalysesLocally, - reconcileMergedAnalysis, - sumUsages, - getAiErrorDetail, - isRetryableAiError, - getAiRetryDelayMs, - sanitizePublicError, - }, -} = require('../src/services/csQualityService'); - -test('chat data metadata exposes the actual available date range', () => { - assert.deepEqual(mapChatDataMeta({ - minDate: '2026-06-22', - latestDate: '2026-07-31 23:59:59', - messageCount: '792771', - }), { - tableName: 'rpt.pdd_GoodsChatMessages', - minDate: '2026-06-22', - latestDate: '2026-07-31', - messageCount: 792771, - }); -}); - -function message(uid, content, index = 1) { - return { - uid, - nickname: `买家-${uid}`, - msgId: `${uid}-${index}`, - msgTime: `2026-07-30 10:00:${String(index).padStart(2, '0')}`, - directionLabel: '买家', - csname: '客服A', - content, - }; -} - -function result(overrides = {}) { - return { - consultation_type: '售前', - primary_reason: '产品问题', - secondary_reason: '尺寸问题', - occurrence_count: 1, - affected_customer_count: 1, - affected_conversation_ids: ['u1'], - buyer_intent: '咨询尺寸', - evidence: [{ conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' }], - image_urls: [], - product_improvement_areas: ['详情页'], - improvement_suggestion: '补充尺寸图', - confidence: 0.9, - ...overrides, - }; -} - -test('customer-service quality defaults to the verified Qwen model and endpoint', () => { - const config = resolveAiConfig({ apiKey: 'test-key' }); - assert.equal(QWEN_PROVIDER, 'aliyun-model-studio'); - assert.equal(config.provider, QWEN_PROVIDER); - assert.equal(config.baseUrl, DEFAULT_QWEN_BASE_URL); - assert.equal(config.model, DEFAULT_QWEN_MODEL); - assert.equal(config.batchConcurrency, 2); - assert.equal(config.batchMaxChars, 45000); - assert.equal(config.mergeMaxBatches, 8); - assert.equal(config.requestRetryCount, 2); - assert.equal(config.retryBaseDelayMs, 1500); -}); - -test('customer-service quality resolves the Agnes China endpoint and request format', () => { - const config = resolveAiConfig({ - provider: AGNES_PROVIDER, - apiKey: 'test-key', - }); - assert.equal(config.provider, AGNES_PROVIDER); - assert.equal(config.baseUrl, DEFAULT_AGNES_BASE_URL); - assert.equal(config.model, DEFAULT_AGNES_MODEL); - assert.equal(config.timeoutMs, 600000); - - const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048); - assert.equal(payload.model, DEFAULT_AGNES_MODEL); - assert.equal(payload.max_tokens, 2048); - assert.equal(payload.temperature, 0); - assert.equal(payload.stream, false); - assert.equal('max_completion_tokens' in payload, false); - assert.equal('response_format' in payload, false); -}); - -test('Qwen requests retain the provider-specific JSON parameters', () => { - const config = resolveAiConfig({ apiKey: 'test-key' }); - const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048); - assert.equal(payload.max_completion_tokens, 2048); - assert.deepEqual(payload.response_format, { type: 'json_object' }); - assert.equal(payload.enable_thinking, false); - assert.equal('max_tokens' in payload, false); -}); - -test('chat batching keeps every customer conversation intact', () => { - const rows = [ - message('u1', '甲'.repeat(700), 1), - message('u1', '乙'.repeat(700), 2), - message('u2', '丙'.repeat(700), 1), - message('u3', '丁'.repeat(700), 1), - ]; - const batches = createChatBatches(rows, 1300); - - assert.equal(batches.length, 3); - assert.deepEqual(batches[0].conversationIds, ['u1']); - assert.equal(batches[0].messageCount, 2); - assert.ok(batches.every((batch) => !( - batch.conversationIds.includes('u1') && batch.conversationIds.length > 1 - ))); - assert.deepEqual(batches.flatMap((batch) => batch.conversationIds), ['u1', 'u2', 'u3']); - assert.equal(batches[0].startDate, '2026-07-30'); - assert.equal(batches[0].endDate, '2026-07-30'); -}); - -test('chat dimension snapshot derives goods and customers in one aggregation', () => { - const snapshot = aggregateChatDimensionRows([ - { - goodsId: 'g1', uid: 'u1', csname: '客服A', goodsName: '商品1', shopName: '店铺1', - nickname: '客户1', messageCount: 2, firstMsgTime: '2026-07-29T08:00:00.000Z', lastMsgTime: '2026-07-29T08:01:00.000Z', - }, - { - goodsId: 'g1', uid: 'u1', csname: '客服B', goodsName: '商品1', shopName: '店铺1', - nickname: '客户1', messageCount: 3, firstMsgTime: '2026-07-29T08:02:00.000Z', lastMsgTime: '2026-07-29T08:04:00.000Z', - }, - { - goodsId: 'g1', uid: 'u2', csname: '客服A', goodsName: '商品1', shopName: '店铺1', - nickname: '客户2', messageCount: 1, firstMsgTime: '2026-07-29T09:00:00.000Z', lastMsgTime: '2026-07-29T09:00:00.000Z', - }, - { - goodsId: 'g2', uid: 'u3', csname: '客服C', goodsName: '商品2', shopName: '店铺2', - nickname: '客户3', messageCount: 4, firstMsgTime: '2026-07-29T10:00:00.000Z', lastMsgTime: '2026-07-29T10:03:00.000Z', - }, - ]); - - assert.equal(snapshot.goodsRows.length, 2); - assert.equal(snapshot.goodsRows[0].goodsId, 'g1'); - assert.equal(snapshot.goodsRows[0].messageCount, 6); - assert.equal(snapshot.goodsRows[0].userCount, 2); - assert.equal(snapshot.goodsRows[0].csCount, 2); - assert.equal(snapshot.customersByGoods.g1.length, 2); - assert.equal(snapshot.customersByGoods.g1[0].uid, 'u1'); - assert.equal(snapshot.customersByGoods.g1[0].messageCount, 5); - assert.equal(snapshot.customersByGoods.g1[0].csname, '客服B'); - assert.equal(snapshot.customersByGoods.g1[0].firstMsgTime, '2026-07-29T08:00:00.000Z'); - assert.equal(snapshot.customersByGoods.g1[0].lastMsgTime, '2026-07-29T08:04:00.000Z'); -}); - -test('local merge sums disjoint batch counts and deduplicates evidence', () => { - const analyses = [ - { - summary: { pre_sale_conversation_count: 1, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] }, - results: [result()], - }, - { - summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] }, - results: [result({ - occurrence_count: 3, - affected_customer_count: 2, - affected_conversation_ids: ['u1', 'u2'], - evidence: [ - { conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' }, - { conversation_id: 'u2', nickname: '买家-u2', message_time: '2026-07-30 10:00:02', text: '多宽' }, - ], - })], - }, - ]; - const merged = mergeBatchAnalysesLocally(analyses, { customerCount: 3, messageCount: 12 }); - - assert.equal(merged.results.length, 1); - assert.equal(merged.results[0].occurrence_count, 4); - assert.equal(merged.results[0].affected_customer_count, 3); - assert.deepEqual(merged.results[0].affected_conversation_ids, ['u1', 'u2']); - assert.equal(merged.results[0].evidence.length, 2); - assert.equal(merged.summary.conversation_count, 3); - assert.equal(merged.summary.pre_sale_conversation_count, 3); - assert.deepEqual(merged.summary.main_findings, ['尺寸咨询较多']); -}); - -test('model merge cannot change deterministic counts or drop a label group', () => { - const local = mergeBatchAnalysesLocally([{ - summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: [] }, - results: [result({ occurrence_count: 4, affected_customer_count: 2 })], - }], { customerCount: 2, messageCount: 8 }); - const model = { - schema_version: '1.0', - conversation_type: '售前', - summary: { conversation_count: 99, pre_sale_conversation_count: 99, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['优化后的结论'] }, - results: [result({ occurrence_count: 99, affected_customer_count: 99, buyer_intent: '优化后的诉求' })], - }; - const reconciled = reconcileMergedAnalysis(model, local, { customerCount: 2, messageCount: 8 }); - - assert.equal(reconciled.results[0].occurrence_count, 4); - assert.equal(reconciled.results[0].affected_customer_count, 2); - assert.equal(reconciled.results[0].buyer_intent, '优化后的诉求'); - assert.deepEqual(reconciled.results[0].affected_conversation_ids, ['u1']); - assert.equal(reconciled.summary.conversation_count, 2); - assert.equal(reconciled.summary.pre_sale_conversation_count, 2); -}); - -test('analysis normalization keeps every valid affected conversation id', () => { - const normalized = normalizeAnalysis({ - summary: {}, - results: [{ - consultation_type: '售前', - primary_reason: '产品问题', - secondary_reason: '尺寸问题', - occurrence_count: 3, - affected_customer_count: 3, - affected_conversation_ids: ['u1', 'u2', 'not-in-batch'], - evidence: [{ conversation_id: 'u3', text: '尺寸是多少' }], - }], - }, { customerCount: 3, messageCount: 10 }, { - allowedConversationIds: ['u1', 'u2', 'u3'], - }); - - assert.deepEqual(normalized.results[0].affected_conversation_ids, ['u1', 'u2', 'u3']); - assert.equal(normalized.results[0].affected_customer_count, 3); -}); - -test('Qwen JSON parsing, usage aggregation and error extraction are provider-safe', () => { - assert.deepEqual(parseModelJson('```json\n{"ok":true}\n```'), { ok: true }); - assert.deepEqual(sumUsages([ - { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 }, - { input_tokens: 7, output_tokens: 2 }, - ]), { prompt_tokens: 17, completion_tokens: 5, total_tokens: 22 }); - assert.equal(getAiErrorDetail({ response: { data: { error: { message: 'rate limited' } } } }), 'rate limited'); - assert.equal(getAiErrorDetail({ message: 'Bearer sk-secret-example-1234567890 failed' }), 'Bearer [redacted] failed'); - assert.equal(isRetryableAiError({ code: 'ETIMEDOUT' }), true); - assert.equal(isRetryableAiError({ response: { status: 429 } }), true); - assert.equal(isRetryableAiError({ response: { status: 401 } }), false); - assert.equal(getAiRetryDelayMs({}, 1, 1500), 1500); - assert.equal(getAiRetryDelayMs({}, 2, 1500), 3000); - assert.equal(getAiRetryDelayMs({ response: { headers: { 'retry-after': '2' } } }, 1, 1500), 2000); - assert.equal(sanitizePublicError('千问模型请求失败:qwen-timeout'), 'AI服务请求失败:AI服务'); - assert.equal(sanitizePublicError('https://apihub.agnes-ai.com/v1 failed'), 'https://AI服务/v1 failed'); -}); diff --git a/backend/test/desktopReleaseService.test.js b/backend/test/desktopReleaseService.test.js deleted file mode 100644 index a8da9db..0000000 --- a/backend/test/desktopReleaseService.test.js +++ /dev/null @@ -1,60 +0,0 @@ -const assert = require('node:assert/strict'); -const test = require('node:test'); - -const { - compareVersions, - normalizeArchitecture, - normalizePlatform, - parseReleaseStorage, - validateVersion, -} = require('../src/services/desktopReleaseService'); - -test('desktop release versions are compared numerically', () => { - assert.equal(compareVersions('0.0.10', '0.0.9'), 1); - assert.equal(compareVersions('v1.2.3', '1.2.3'), 0); - assert.equal(compareVersions('2.0.0', '10.0.0'), -1); -}); - -test('desktop release versions require an x.y.z value', () => { - assert.equal(validateVersion('v1.2.3'), '1.2.3'); - assert.throws(() => validateVersion('1.2'), /版本号格式/); -}); - -test('desktop release storage drops invalid records without losing the latest version', () => { - const storage = parseReleaseStorage(JSON.stringify({ - latestVersion: '0.0.9', - releases: [ - { id: 9, version: '0.0.9', objectKey: 'desktop-client/0.0.9/setup.exe' }, - { id: 8, version: 'invalid', objectKey: 'desktop-client/invalid/setup.exe' }, - ], - })); - - assert.equal(storage.latestVersion, '0.0.9'); - assert.equal(storage.releases.length, 1); - assert.equal(storage.releases[0].version, '0.0.9'); - assert.equal(storage.releases[0].platform, 'win32'); - assert.equal(storage.releases[0].architecture, 'x64'); - assert.equal(storage.latestVersions.win32, '0.0.9'); -}); - -test('desktop release platform and architecture are inferred from installers', () => { - assert.equal(normalizePlatform('', 'pdd-auto-sale-0.0.10-arm64.dmg'), 'darwin'); - assert.equal(normalizePlatform('', 'pdd-auto-sale-0.0.10-setup.exe'), 'win32'); - assert.equal(normalizeArchitecture('', 'pdd-auto-sale-0.0.10-arm64.dmg', 'darwin'), 'arm64'); - assert.equal(normalizeArchitecture('', 'pdd-auto-sale-0.0.10-setup.exe', 'win32'), 'x64'); -}); - -test('desktop release storage keeps Windows and macOS installers for the same version', () => { - const storage = parseReleaseStorage({ - latestVersions: { win32: '0.0.10', darwin: '0.0.10' }, - releases: [ - { id: 10, version: '0.0.10', objectKey: 'desktop-client/win32/0.0.10/setup.exe', fileName: 'pdd-auto-sale-0.0.10-setup.exe' }, - { id: 11, version: '0.0.10', objectKey: 'desktop-client/darwin/0.0.10/app.dmg', fileName: 'pdd-auto-sale-0.0.10-arm64.dmg' }, - ], - }); - - assert.equal(storage.releases.length, 2); - assert.deepEqual(storage.releases.map((release) => release.platform), ['win32', 'darwin']); - assert.equal(storage.latestVersions.win32, '0.0.10'); - assert.equal(storage.latestVersions.darwin, '0.0.10'); -}); diff --git a/backend/test/desktopSessionService.test.js b/backend/test/desktopSessionService.test.js index 2091bee..b48e80c 100644 --- a/backend/test/desktopSessionService.test.js +++ b/backend/test/desktopSessionService.test.js @@ -95,7 +95,6 @@ test('desktop capabilities follow resolved page permissions', () => { marketingEvents: false, promotion: false, productTest: false, - reviewAnalysis: false, }); assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).promotion, true); }); diff --git a/backend/test/externalAutomationSharedAuth.test.js b/backend/test/externalAutomationSharedAuth.test.js index c86c9a9..0e3111c 100644 --- a/backend/test/externalAutomationSharedAuth.test.js +++ b/backend/test/externalAutomationSharedAuth.test.js @@ -5,7 +5,6 @@ const assert = require('node:assert/strict'); const jwt = require('jsonwebtoken'); const presaleRouter = require('../src/api/presaleExternal'); const autoListingRouter = require('../src/api/planningSelectionFissionExternal'); -const pricingRouter = require('../src/api/pricingCalculatorExternal'); const alibaba1688Router = require('../src/api/alibaba1688ProductExternal'); const { requireDesktopPageAccess, @@ -38,40 +37,6 @@ test('presale and auto-listing external APIs use the same request key middleware assert.equal(getRouteMiddleware(alibaba1688Router, '/:productId'), requireExternalAutomationApiKey); }); -test('desktop pricing uses the shared request key middleware', () => { - const middlewareLayer = pricingRouter.stack.find((item) => item.name === 'requireExternalAutomationApiKey'); - assert.equal(middlewareLayer?.handle, requireExternalAutomationApiKey); -}); - -test('desktop pricing identity is stable, isolated and uses negative safe integers', () => { - const first = pricingRouter.desktopPricingUserId('香茗'); - assert.equal(first, pricingRouter.desktopPricingUserId(' 香茗 ')); - assert.notEqual(first, pricingRouter.desktopPricingUserId('江陵')); - assert.equal(Number.isSafeInteger(first), true); - assert.equal(first < 0, true); -}); - -test('desktop pricing accepts a UTF-8 base64url nickname header', () => { - const nickname = '胖大海'; - const req = { - get(name) { - return name.toLowerCase() === 'x-operator-name-encoded' - ? Buffer.from(nickname, 'utf8').toString('base64url') - : ''; - }, - query: {}, - body: {}, - }; - let nextCalled = false; - - pricingRouter.requireDesktopPricingOperator(req, {}, () => { nextCalled = true; }); - - assert.equal(nextCalled, true); - assert.equal(req.user.name, nickname); - assert.equal(req.user.id, req.user.sub); - assert.ok(req.user.id < 0); -}); - test('shared external auth accepts a valid desktop JWT without an API key', async () => { const previousSecret = process.env.CUBEJS_SECRET; process.env.CUBEJS_SECRET = 'desktop-session-test-secret'; @@ -178,20 +143,3 @@ test('desktop page access enforces JWT permissions and preserves API key compati assert.equal(nextCalled, true); } }); - -test('desktop pricing keeps the verified JWT user identity', () => { - const req = { - externalApiKey: { authType: 'jwt' }, - user: { sub: '42', id: 42, name: '陆南亭' }, - get() { return ''; }, - query: {}, - body: {}, - }; - let nextCalled = false; - - pricingRouter.requireDesktopPricingOperator(req, {}, () => { nextCalled = true; }); - - assert.equal(nextCalled, true); - assert.equal(req.user.id, 42); - assert.equal(req.user.name, '陆南亭'); -}); diff --git a/backend/test/pricingCalculatorSelection.test.js b/backend/test/pricingCalculatorSelection.test.js deleted file mode 100644 index 839e3a9..0000000 --- a/backend/test/pricingCalculatorSelection.test.js +++ /dev/null @@ -1,110 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { - _test: { - filterSkuRowsForSelectionContext, - createPrefsPatch, - getSelectionSkuCodeSet, - normalizeCalculatorType, - readPrefsProfile, - sanitizeHistoryProduct, - writePrefsProfile, - }, -} = require('../src/api/pricingCalculator'); - -const skuRows = [ - { skuCode: 'SKU-A' }, - { skuCode: 'SKU-B' }, - { skuCode: 'SKU-C' }, -]; - -test('selection pricing only returns explicitly selected SKU rows', () => { - const selectionContext = { - skuSceneImages: { selectedSkuCodes: ['sku-a', 'SKU-C'] }, - rows: [{ skuCode: 'SKU-A' }, { skuCode: 'SKU-B' }, { skuCode: 'SKU-C' }], - }; - - assert.deepEqual( - filterSkuRowsForSelectionContext(skuRows, selectionContext), - [{ skuCode: 'SKU-A' }, { skuCode: 'SKU-C' }] - ); -}); - -test('selection pricing falls back to stored pricing rows for legacy tasks', () => { - const selectionContext = { - skuSceneImages: {}, - rows: [{ skuCode: 'SKU-B' }], - }; - - assert.deepEqual([...getSelectionSkuCodeSet(selectionContext)], ['SKU-B']); - assert.deepEqual( - filterSkuRowsForSelectionContext(skuRows, selectionContext), - [{ skuCode: 'SKU-B' }] - ); -}); - -test('standalone pricing continues to return all SPU SKU rows', () => { - assert.deepEqual(filterSkuRowsForSelectionContext(skuRows, null), skuRows); -}); - -test('pricing preferences keep legacy company values and isolate 1688 defaults', () => { - const legacy = JSON.stringify({ - coupon: 30, - activityDiscount: 0.7, - installFee: 5, - targetMargin: 0.15, - }); - const withDropship = writePrefsProfile(legacy, 'alibaba1688', { - coupon: 20, - activityDiscount: 0.6, - installFee: 0, - targetMargin: 0.12, - }); - - assert.deepEqual(readPrefsProfile(withDropship, 'company'), { - coupon: 30, - activityDiscount: 0.7, - installFee: 5, - targetMargin: 0.15, - }); - assert.deepEqual(readPrefsProfile(withDropship, 'alibaba1688'), { - coupon: 20, - activityDiscount: 0.6, - installFee: 0, - targetMargin: 0.12, - }); - - const updatedCompany = writePrefsProfile(withDropship, 'company', { targetMargin: 0.18 }); - assert.equal(readPrefsProfile(updatedCompany, 'company').targetMargin, 0.18); - assert.equal(readPrefsProfile(updatedCompany, 'alibaba1688').targetMargin, 0.12); - assert.deepEqual(createPrefsPatch('company', { targetMargin: 0.15 }), { targetMargin: 0.15 }); - assert.deepEqual(createPrefsPatch('alibaba1688', { targetMargin: 0.12 }), { - _profiles: { alibaba1688: { targetMargin: 0.12 } }, - }); -}); - -test('1688 history product keeps bounded identity and specification dimensions', () => { - assert.equal(normalizeCalculatorType('alibaba1688'), 'alibaba1688'); - assert.equal(normalizeCalculatorType('unknown'), 'company'); - assert.deepEqual(sanitizeHistoryProduct({ - productId: ' 1073252781818 ', - title: ' 测试商品 ', - skuDimensions: [ - { name: '颜色', values: ['原木色', '胡桃色'] }, - { name: '尺寸', values: ['60cm'] }, - ], - }), { - productId: '1073252781818', - title: '测试商品', - mainImage: '', - sourceUrl: '', - storeName: '', - category: '', - skuDimensions: [ - { name: '颜色', values: ['原木色', '胡桃色'] }, - { name: '尺寸', values: ['60cm'] }, - ], - }); -}); diff --git a/backend/test/retiredFeatures.test.js b/backend/test/retiredFeatures.test.js index acaadb7..7619ad1 100644 --- a/backend/test/retiredFeatures.test.js +++ b/backend/test/retiredFeatures.test.js @@ -1,14 +1,26 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { pruneRetiredSidebarConfig, cleanupRetiredFeatureSettings, isRetiredSystemConfigKey } = require('../src/services/retiredFeatures'); +const { + pruneRetiredSidebarConfig, + cleanupRetiredFeatureSettings, + isRetiredDatabaseConfigName, + isRetiredSystemConfigKey, +} = require('../src/services/retiredFeatures'); test('retired sidebar cleanup is recursive and idempotent', () => { - const saved = JSON.stringify({ entries: [{ key: 'agent' }, { key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'settings', label: '管理设置' }] }] }); + const saved = JSON.stringify({ entries: [ + { key: 'agent' }, + { key: 'store-management', children: [{ key: 'store-checkup' }] }, + { key: 'operation-tools', children: [{ key: 'pricing-calculator' }] }, + { key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'desktop-client-releases' }, { key: 'settings', label: '管理设置' }] }, + ] }); const clean = pruneRetiredSidebarConfig(saved); assert.deepEqual(JSON.parse(clean).entries, [{ key: 'manage', children: [{ key: 'settings', label: '管理设置' }] }]); assert.equal(pruneRetiredSidebarConfig(clean), clean); assert.equal(pruneRetiredSidebarConfig('{invalid'), '{invalid'); assert.equal(isRetiredSystemConfigKey('openclaw_chat_skill_tags'), true); + assert.equal(isRetiredSystemConfigKey('cs_quality_ai_config'), true); + assert.equal(isRetiredDatabaseConfigName('ai_buyer_show_database'), true); assert.equal(isRetiredSystemConfigKey('site_name'), false); }); @@ -19,8 +31,9 @@ test('upgrade removes only retired settings and permission rows', async () => { return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"}]}' }] : []; } }); const deletes = calls.filter((call) => call.sql.startsWith('DELETE')); - assert.equal(deletes.length, 4); + assert.equal(deletes.length, 5); assert.ok(deletes.every((call) => call.sql.includes(' WHERE ') && call.params.length > 0)); + assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 2); const update = calls.find((call) => call.sql.startsWith('UPDATE')); assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'product-analysis' }]); }); diff --git a/frontend/src/api/aiBuyerShow.js b/frontend/src/api/aiBuyerShow.js deleted file mode 100644 index 47e1132..0000000 --- a/frontend/src/api/aiBuyerShow.js +++ /dev/null @@ -1,74 +0,0 @@ -import request from './request'; - -const AI_BUYER_SHOW_TIMEOUT_MS = 5 * 60 * 1000; - -export const getAiBuyerShowRuntime = () => ( - request.get('/api/ai-buyer-show/runtime', { timeout: 30000 }) -); - -export const previewAiBuyerShowPrompt = (data) => ( - request.post('/api/ai-buyer-show/prompt-preview', data, { timeout: 30000 }) -); - -export const generateAiBuyerShowReviews = ({ values = {}, productImages = [] }) => { - const formData = new FormData(); - Object.entries(values).forEach(([key, value]) => { - if (value !== undefined && value !== null && String(value).trim() !== '') { - formData.append(key, Array.isArray(value) ? value.join(',') : value); - } - }); - productImages.forEach((file) => { - const rawFile = file.originFileObj || file; - if (rawFile instanceof Blob) formData.append('productImages', rawFile); - }); - return request.post('/api/ai-buyer-show/reviews', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - timeout: AI_BUYER_SHOW_TIMEOUT_MS, - maxBodyLength: Infinity, - maxContentLength: Infinity, - }); -}; - -export const createAiBuyerShowJob = ({ values = {}, productImages = [], sceneReferenceImages = [] }) => { - const formData = new FormData(); - Object.entries(values).forEach(([key, value]) => { - if (value !== undefined && value !== null && String(value).trim() !== '') { - formData.append(key, value); - } - }); - productImages.forEach((file) => { - const rawFile = file.originFileObj || file; - if (rawFile instanceof Blob) formData.append('productImages', rawFile); - }); - sceneReferenceImages.forEach((file) => { - const rawFile = file.originFileObj || file; - if (rawFile instanceof Blob) formData.append('sceneReferenceImages', rawFile); - }); - - return request.post('/api/ai-buyer-show/jobs', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - timeout: AI_BUYER_SHOW_TIMEOUT_MS, - maxBodyLength: Infinity, - maxContentLength: Infinity, - }); -}; - -export const getAiBuyerShowJobs = (params) => ( - request.get('/api/ai-buyer-show/jobs', { params, timeout: 30000 }) -); - -export const getAiBuyerShowHistory = (params) => ( - request.get('/api/ai-buyer-show/history', { params, timeout: 30000 }) -); - -export const getAiBuyerShowHistoryDetail = (id) => ( - request.get(`/api/ai-buyer-show/history/${id}`, { timeout: 30000 }) -); - -export const getAiBuyerShowJob = (jobId) => ( - request.get(`/api/ai-buyer-show/jobs/${jobId}`, { timeout: 30000 }) -); - -export const getAiBuyerShowQueueStats = (params) => ( - request.get('/api/ai-buyer-show/queue-stats', { params, timeout: 30000 }) -); diff --git a/frontend/src/api/csQuality.js b/frontend/src/api/csQuality.js deleted file mode 100644 index 3d66fa2..0000000 --- a/frontend/src/api/csQuality.js +++ /dev/null @@ -1,44 +0,0 @@ -import request from './request'; - -export const getCsQualityDataMeta = () => ( - request.get('/api/cs-quality/data-meta', { timeout: 120000 }) -); - -export const getCsQualityGoods = (params) => ( - request.get('/api/cs-quality/goods', { params, timeout: 120000 }) -); - -export const getCsQualityCustomers = (params) => ( - request.get('/api/cs-quality/customers', { params, timeout: 120000 }) -); - -export const getCsQualityMessages = (params) => ( - request.get('/api/cs-quality/messages', { params, timeout: 120000 }) -); - -export const getCsQualityProductProfile = (goodsId, params) => ( - request.get(`/api/cs-quality/goods/${encodeURIComponent(goodsId)}/profile`, { params, timeout: 120000 }) -); - -export const runCsQualityAiAnalysis = (data) => ( - request.post('/api/cs-quality/analysis', data, { timeout: 900000 }) -); - -export const startCsQualityAiAnalysis = (data) => ( - request.post('/api/cs-quality/analysis-jobs', data, { timeout: 120000, silentError: true }) -); - -export const getCsQualityAiAnalysisJob = (analysisId) => ( - request.get(`/api/cs-quality/analysis-jobs/${encodeURIComponent(analysisId)}`, { - timeout: 30000, - silentError: true, - }) -); - -export const getCsQualityAnalysisHistory = (params) => ( - request.get('/api/cs-quality/analysis-history', { params, timeout: 120000 }) -); - -export const getCsQualityAnalysisHistoryDetail = (id) => ( - request.get(`/api/cs-quality/analysis-history/${encodeURIComponent(id)}`, { timeout: 120000 }) -); diff --git a/frontend/src/api/desktopClient.js b/frontend/src/api/desktopClient.js deleted file mode 100644 index 7bbdc20..0000000 --- a/frontend/src/api/desktopClient.js +++ /dev/null @@ -1,34 +0,0 @@ -import request from './request'; - -export const getLatestDesktopRelease = (currentVersion, platform = 'win32', architecture = '') => ( - request.get('/api/desktop-client/latest', { - params: { - ...(currentVersion ? { currentVersion } : {}), - platform, - ...(architecture ? { architecture } : {}), - }, - silentError: true, - }) -); - -export const getDesktopReleases = () => ( - request.get('/api/admin/desktop-releases', { silentError: true }) -); - -export const publishDesktopRelease = ({ version, releaseNotes, installer, platform, architecture, onUploadProgress }) => { - const formData = new FormData(); - formData.append('version', version); - formData.append('releaseNotes', releaseNotes || ''); - formData.append('platform', platform || 'win32'); - formData.append('architecture', architecture || (platform === 'darwin' ? 'arm64' : 'x64')); - formData.append('installer', installer); - - return request.post('/api/admin/desktop-releases', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - timeout: 30 * 60 * 1000, - maxBodyLength: Infinity, - maxContentLength: Infinity, - onUploadProgress, - silentError: true, - }); -}; diff --git a/frontend/src/api/pricingCalculator.js b/frontend/src/api/pricingCalculator.js deleted file mode 100644 index 72bbba7..0000000 --- a/frontend/src/api/pricingCalculator.js +++ /dev/null @@ -1,79 +0,0 @@ -import request from './request'; - -// 统一处理 { success, data, error } 包裹:业务错误抛出带友好文案的 Error, -// 由页面以行内状态展示,避免 axios 默认的 "Request failed with status code xxx"。 -const unwrap = async (promise) => { - try { - const res = await promise; - if (res && res.success === true) { - return res.data; - } - throw new Error(res?.error || '请求失败,请稍后重试'); - } catch (error) { - const friendly = error?.response?.data?.error; - if (friendly) { - throw new Error(friendly); - } - throw error; - } -}; - -// 业务错误(404/400)也以 resolve 返回,交给 unwrap 解析友好文案 -const tolerantConfig = { validateStatus: (status) => status < 500 }; - -export const fetchSpuSkus = (spuCode, params = {}) => unwrap( - request.get(`/api/pricing-calculator/spu/${encodeURIComponent(spuCode)}`, { - ...tolerantConfig, - params, - }) -); - -export const fetchProductCodeMeta = () => unwrap( - request.get('/api/pricing-calculator/product-code/meta', tolerantConfig) -); - -export const savePricingHistory = (payload) => unwrap( - request.post('/api/pricing-calculator/history', payload, tolerantConfig) -); - -export const fetchPricingHistoryList = (params) => unwrap( - request.get('/api/pricing-calculator/history', { ...tolerantConfig, params }) -); - -export const fetchPricingHistoryDetail = (id) => unwrap( - request.get(`/api/pricing-calculator/history/${id}`, tolerantConfig) -); - -export const deletePricingHistory = (id) => unwrap( - request.delete(`/api/pricing-calculator/history/${id}`, tolerantConfig) -); - -// 导出为 xlsx:后端生成文件流。注意 request 的响应拦截器已把返回值解包为响应体本身, -// 成功时是 xlsx 的 Blob;业务错误(400/500)时后端返回 JSON,此处也是 Blob(type 为 application/json),需解析错误文案 -export const exportPricingTable = async (payload) => { - const blob = await request.post('/api/pricing-calculator/export', payload, { - responseType: 'blob', - validateStatus: () => true, // 状态码已被拦截器抹掉,靠 Blob 类型区分成功与错误 - }); - if (blob instanceof Blob && blob.type.includes('json')) { - let friendly = '导出失败,请稍后重试'; - try { - const parsed = JSON.parse(await blob.text()); - friendly = parsed?.error || friendly; - } catch { /* 保持默认文案 */ } - throw new Error(friendly); - } - return blob; -}; - -// 个性化默认值:读取/保存当前登录用户的配置(无配置时 prefs 为 null,前端回退系统默认) -export const fetchPricingPrefs = (profile = 'company') => unwrap( - request.get('/api/pricing-calculator/prefs', { - ...tolerantConfig, - params: { profile }, - }) -); - -export const savePricingPrefs = (prefs, profile = 'company') => unwrap( - request.put('/api/pricing-calculator/prefs', { prefs, profile }, tolerantConfig) -); diff --git a/frontend/src/api/storeCheckup.js b/frontend/src/api/storeCheckup.js deleted file mode 100644 index f1d8953..0000000 --- a/frontend/src/api/storeCheckup.js +++ /dev/null @@ -1,23 +0,0 @@ -import request from './request'; - -export const getStoreCheckupMeta = () => request.get('/api/store-checkup/meta'); - -export const getStoreCheckupOverview = (params) => ( - request.get('/api/store-checkup/overview', { params }) -); - -export const getStoreCheckupHomeSummary = (params, config = {}) => ( - request.get('/api/store-checkup/home-summary', { ...config, params }) -); - -export const getStoreCheckupHomeReviews = (params, config = {}) => ( - request.get('/api/store-checkup/home-reviews', { ...config, params }) -); - -export const getStoreCheckupHomeCompensation = (params, config = {}) => ( - request.get('/api/store-checkup/home-compensation', { ...config, params }) -); - -export const getStoreCheckupTab = (tabKey, params) => ( - request.get(`/api/store-checkup/tabs/${tabKey}`, { params }) -); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 2ee46ff..78b4cca 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -18,29 +18,11 @@ export const DEFAULT_SIDEBAR_CONFIG = { { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 }, ], }, - { - key: 'store-management', type: 'group', label: '店铺管理', icon: 'shop', visible: true, order: 80, defaultExpanded: true, - children: [ - { key: 'store-checkup', label: '店铺体检', icon: 'check-circle', routeKey: 'store-checkup', visible: true, order: 10 }, - { key: 'review-analysis', label: '复盘分析', icon: 'history', routeKey: 'review-analysis', visible: true, order: 20 }, - { key: 'customer-service-checkup', label: '客服质检', icon: 'customer-service', routeKey: 'customer-service-checkup', visible: true, order: 30 }, - ], - }, - { - key: 'operation-tools', type: 'group', label: '运营工具', icon: 'tool', visible: true, order: 90, defaultExpanded: true, - children: [ - { key: 'pricing-calculator', label: '上架测算', icon: 'calculator', routeKey: 'pricing-calculator', visible: true, order: 5 }, - { key: 'desktop-client-download', label: '客户端下载', icon: 'desktop', routeKey: 'desktop-client-download', visible: true, order: 8 }, - { key: 'ai-buyer-show', label: 'AI买家秀', icon: 'image', routeKey: 'ai-buyer-show', visible: true, order: 10 }, - { key: 'ai-seeding-video', label: 'AI种草视频', icon: 'video', routeKey: 'ai-seeding-video', visible: true, order: 20 }, - ], - }, { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, { key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true, children: [ { key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true }, - { key: 'desktop-client-releases', label: '客户端发布', icon: 'desktop', routeKey: 'desktop-client-releases', visible: true, order: 20, requiresAdmin: true }, { key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true }, { key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true }, ], diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index ee71f30..f76eecb 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -2,33 +2,25 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { AppstoreOutlined, - CalculatorOutlined, - CheckCircleOutlined, CloudOutlined, - CustomerServiceOutlined, DashboardOutlined, DesktopOutlined, DownOutlined, ExperimentOutlined, FundProjectionScreenOutlined, - HistoryOutlined, LineChartOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, MessageOutlined, MoonOutlined, - PictureOutlined, RightOutlined, RobotOutlined, SettingOutlined, - ShopOutlined, ShoppingOutlined, TableOutlined, TeamOutlined, - ToolOutlined, UserOutlined, - VideoCameraOutlined, SunOutlined, } from '@ant-design/icons'; import { Dropdown, Switch } from 'antd'; @@ -64,26 +56,18 @@ const readSidebarDarkModePreference = () => { const SIDEBAR_ICON_MAP = { appstore: AppstoreOutlined, - calculator: CalculatorOutlined, - 'check-circle': CheckCircleOutlined, cloud: CloudOutlined, - 'customer-service': CustomerServiceOutlined, dashboard: DashboardOutlined, desktop: DesktopOutlined, experiment: ExperimentOutlined, - history: HistoryOutlined, - image: PictureOutlined, 'line-chart': LineChartOutlined, message: MessageOutlined, projection: FundProjectionScreenOutlined, robot: RobotOutlined, setting: SettingOutlined, - shop: ShopOutlined, shopping: ShoppingOutlined, table: TableOutlined, team: TeamOutlined, - tool: ToolOutlined, - video: VideoCameraOutlined, }; const SIDEBAR_ROUTE_MAP = { @@ -94,19 +78,11 @@ const SIDEBAR_ROUTE_MAP = { matchMode: 'prefix', }, 'promotion-module': { path: '/planning/operation/promotion-module' }, - 'pricing-calculator': { path: '/planning/operation/pricing-calculator' }, 'product-monitoring': { path: '/planning/operation/product-monitoring' }, 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, - 'desktop-client-download': { path: '/planning/operation/desktop-client-download' }, 'product-analysis': { path: '/planning/operation/product-analysis' }, - 'store-checkup': { path: '/planning/operation/store-checkup' }, - 'review-analysis': { path: '/planning/operation/review-analysis' }, - 'customer-service-checkup': { path: '/planning/operation/customer-service-checkup' }, - 'ai-buyer-show': { path: '/planning/operation/ai-buyer-show' }, - 'ai-seeding-video': { path: '/planning/operation/ai-seeding-video' }, 'work-orders': { path: '/planning/operation/work-orders' }, settings: { path: '/settings' }, - 'desktop-client-releases': { path: '/settings/desktop-client-releases' }, 'online-desktop-users': { path: '/settings/online-desktop-users' }, 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, }; diff --git a/frontend/src/pages/AiBuyerShow/index.jsx b/frontend/src/pages/AiBuyerShow/index.jsx deleted file mode 100644 index 85750f0..0000000 --- a/frontend/src/pages/AiBuyerShow/index.jsx +++ /dev/null @@ -1,1093 +0,0 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import JSZip from 'jszip'; -import { - Alert, - Button, - Checkbox, - Drawer, - Empty, - Form, - Image, - Input, - Modal, - Progress, - Select, - Space, - Spin, - Tag, - Table, - Upload, - message, -} from 'antd'; -import { - ClockCircleOutlined, - CloudUploadOutlined, - DownloadOutlined, - FileTextOutlined, - HistoryOutlined, - PictureOutlined, - PlusOutlined, - ReloadOutlined, - SearchOutlined, - UserOutlined, -} from '@ant-design/icons'; -import { SycmPage } from '../../components/Common'; -import { - createAiBuyerShowJob, - generateAiBuyerShowReviews, - getAiBuyerShowHistory, - getAiBuyerShowHistoryDetail, - getAiBuyerShowJob, - getAiBuyerShowJobs, - getAiBuyerShowRuntime, - previewAiBuyerShowPrompt, -} from '../../api/aiBuyerShow'; -import './style.css'; - -const PAGE_SIZE = 200; -const HISTORY_PAGE_SIZE = 15; -const ACTIVE_STATUSES = new Set(['pending', 'running', 'retrying']); -const STATUS_META = { - pending: { color: 'processing', label: '待生成' }, - running: { color: 'processing', label: '生图中' }, - retrying: { color: 'warning', label: '重试中' }, - completed: { color: 'success', label: '已完成' }, - partial: { color: 'warning', label: '部分完成' }, - failed: { color: 'error', label: '失败' }, -}; - -const toUploadList = (info, maxCount) => info.fileList.slice(-maxCount).map((file) => ({ - ...file, - status: file.status === 'error' ? 'error' : 'done', -})); - -const getImageUrl = (image) => ( - image?.thumbnailUrl || image?.originalUrl || image?.imageUrl || image?.url || '' -); - -const getOriginalImageUrl = (image) => ( - image?.originalUrl || image?.imageUrl || image?.url || image?.thumbnailUrl || '' -); - -const getReviewImages = (review = {}) => { - const seen = new Set(); - const images = []; - const append = (value) => { - if (!value) return; - if (Array.isArray(value)) { - value.forEach(append); - return; - } - const originalUrl = typeof value === 'string' ? value : getOriginalImageUrl(value); - if (!originalUrl || seen.has(originalUrl)) return; - seen.add(originalUrl); - images.push({ - originalUrl, - thumbnailUrl: typeof value === 'string' ? value : getImageUrl(value), - }); - }; - append(review.buyerShowImages); - append(review.buyerShowImageUrls); - append(review.buyerShowImageUrl); - return images.slice(0, 5); -}; - -const getRecordKey = (record) => String(record?.reviewRecordId || record?.jobId || ''); - -const toRemoteUploadList = (urls, prefix) => (urls || []).filter(Boolean).map((url, index) => ({ - uid: `${prefix}-${index}-${url}`, - name: `${prefix}-${index + 1}`, - status: 'done', - url, -})); - -const safeFileName = (value, fallback = 'buyer-show') => ( - String(value || fallback).replace(/[/:*?"<>|\\]/g, '_').replace(/\s+/g, ' ').trim() || fallback -); - -const resolveImageExtension = (url, contentType) => { - const type = String(contentType || '').toLowerCase(); - if (type.includes('png')) return 'png'; - if (type.includes('webp')) return 'webp'; - if (type.includes('gif')) return 'gif'; - if (type.includes('jpeg') || type.includes('jpg')) return 'jpg'; - const extension = String(url || '').split(/[?#]/)[0].match(/\.([a-z0-9]{2,5})$/i)?.[1]?.toLowerCase(); - return ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(extension) ? extension : 'jpg'; -}; - -const downloadBlob = (filename, blob) => { - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = filename; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); -}; - -const formatDateTime = (value) => { - if (!value) return '-'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return String(value); - return new Intl.DateTimeFormat('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date); -}; - -const getStatusTag = (status) => { - const key = String(status || '').toLowerCase(); - const meta = STATUS_META[key] || { color: 'default', label: status || '-' }; - return {meta.label}; -}; - -const AiBuyerShow = () => { - const [form] = Form.useForm(); - const loadRequestRef = useRef(0); - const sentinelRef = useRef(null); - const spuKeywordRef = useRef(''); - const [runtime, setRuntime] = useState(null); - const [runtimeLoading, setRuntimeLoading] = useState(false); - const [jobs, setJobs] = useState([]); - const [jobsTotal, setJobsTotal] = useState(0); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(false); - const [jobsLoading, setJobsLoading] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [searchInput, setSearchInput] = useState(''); - const [spuKeyword, setSpuKeyword] = useState(''); - const [createOpen, setCreateOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [reviewsLoading, setReviewsLoading] = useState(false); - const [promptLoading, setPromptLoading] = useState(false); - const [productFiles, setProductFiles] = useState([]); - const [sceneFiles, setSceneFiles] = useState([]); - const [currentJob, setCurrentJob] = useState(null); - const [generationResult, setGenerationResult] = useState(null); - const [selectedReviewId, setSelectedReviewId] = useState(null); - const [previewRecord, setPreviewRecord] = useState(null); - const [selectedJobKeys, setSelectedJobKeys] = useState([]); - const [downloadLoading, setDownloadLoading] = useState(false); - const [historyOpen, setHistoryOpen] = useState(false); - const [historyRows, setHistoryRows] = useState([]); - const [historyPagination, setHistoryPagination] = useState({ - current: 1, - pageSize: HISTORY_PAGE_SIZE, - total: 0, - }); - const [historyKeyword, setHistoryKeyword] = useState(''); - const [historyLoading, setHistoryLoading] = useState(false); - - const databaseOk = Boolean(runtime?.database?.ok); - const queue = currentJob?.queue || runtime?.queue || null; - - const loadRuntime = useCallback(async () => { - try { - setRuntimeLoading(true); - const response = await getAiBuyerShowRuntime(); - setRuntime(response.data || null); - } catch (error) { - setRuntime(error.response?.data?.data || null); - } finally { - setRuntimeLoading(false); - } - }, []); - - const loadJobs = useCallback(async ({ - nextPage = 1, - keyword = spuKeywordRef.current, - append = false, - } = {}) => { - const requestId = loadRequestRef.current + 1; - loadRequestRef.current = requestId; - if (append) setLoadingMore(true); - else setJobsLoading(true); - - try { - const response = await getAiBuyerShowJobs({ - page: nextPage, - pageSize: PAGE_SIZE, - spu: keyword || undefined, - includeTotal: append ? 0 : 1, - }); - if (requestId !== loadRequestRef.current) return; - const data = response.data || {}; - const rows = data.rows || []; - if (!append) setSelectedJobKeys([]); - setJobs((previous) => { - if (!append) return rows; - const existing = new Set(previous.map((item) => item.reviewRecordId)); - return [...previous, ...rows.filter((item) => !existing.has(item.reviewRecordId))]; - }); - if (!append) setJobsTotal(Number(data.total || 0)); - setPage(Number(data.page || nextPage)); - setHasMore(Boolean(data.hasMore)); - } catch (error) { - if (requestId !== loadRequestRef.current) return; - if (!append) { - setJobs([]); - setJobsTotal(0); - } - message.error(error.response?.data?.error || '历史买家秀加载失败'); - } finally { - if (requestId === loadRequestRef.current) { - setJobsLoading(false); - setLoadingMore(false); - } - } - }, []); - - const loadJob = useCallback(async (jobId) => { - if (!jobId || String(jobId).startsWith('review-')) return; - const response = await getAiBuyerShowJob(jobId); - const job = response.data || null; - setCurrentJob(job); - if (job && !ACTIVE_STATUSES.has(String(job.status || '').toLowerCase())) { - await Promise.all([loadJobs({ nextPage: 1, append: false }), loadRuntime()]); - } - }, [loadJobs, loadRuntime]); - - useEffect(() => { - loadRuntime(); - loadJobs({ nextPage: 1, keyword: '', append: false }); - }, [loadJobs, loadRuntime]); - - useEffect(() => { - if (!currentJob?.jobId || !ACTIVE_STATUSES.has(String(currentJob.status || '').toLowerCase())) { - return undefined; - } - const timer = window.setInterval(() => { - loadJob(currentJob.jobId).catch(() => {}); - }, 4000); - return () => window.clearInterval(timer); - }, [currentJob?.jobId, currentJob?.status, loadJob]); - - useEffect(() => { - const target = sentinelRef.current; - if (!target || !hasMore || jobsLoading || loadingMore) return undefined; - const observer = new IntersectionObserver((entries) => { - if (entries[0]?.isIntersecting) { - loadJobs({ nextPage: page + 1, append: true }); - } - }, { rootMargin: '240px 0px' }); - observer.observe(target); - return () => observer.disconnect(); - }, [hasMore, jobsLoading, loadingMore, loadJobs, page]); - - const handleSearch = (value) => { - const keyword = String(value || '').trim(); - setSearchInput(keyword); - setSpuKeyword(keyword); - spuKeywordRef.current = keyword; - setPage(1); - loadJobs({ nextPage: 1, keyword, append: false }); - }; - - const handleRefresh = () => { - loadRuntime(); - loadJobs({ nextPage: 1, keyword: spuKeyword, append: false }); - }; - - const loadHistoryRows = async ({ current = historyPagination.current, pageSize = historyPagination.pageSize } = {}) => { - try { - setHistoryLoading(true); - const response = await getAiBuyerShowHistory({ - page: current, - pageSize, - keyword: historyKeyword || undefined, - }); - const data = response.data || {}; - setHistoryRows(data.rows || []); - setHistoryPagination({ - current: Number(data.page || current), - pageSize: Number(data.pageSize || pageSize), - total: Number(data.total || 0), - }); - } catch (error) { - message.error(error.response?.data?.error || error.message || '历史记录加载失败'); - } finally { - setHistoryLoading(false); - } - }; - - const openHistoryDrawer = () => { - setHistoryOpen(true); - loadHistoryRows({ current: 1 }); - }; - - const loadHistoryDetail = async (record) => { - try { - setHistoryLoading(true); - const response = await getAiBuyerShowHistoryDetail(record.id); - const data = response.data || null; - if (!data) return; - const firstReview = data.reviews?.[0] || null; - setGenerationResult({ - generationId: data.generationId, - reviews: data.reviews || [], - imageAnalysis: data.imageAnalysis || {}, - productImageUrls: data.productImageUrls || data.originalImageUrls || [], - }); - setSelectedReviewId(firstReview?.reviewRecordId || null); - const productImageUrls = data.productImageUrls || data.originalImageUrls || []; - setProductFiles(toRemoteUploadList(productImageUrls, 'product-reference')); - setSceneFiles(toRemoteUploadList(firstReview?.buyerShowSceneReferenceImageUrls, 'scene-reference')); - form.setFieldsValue({ - productName: data.spu || data.productName || '', - scene: data.scene || (data.scenes || []).join(','), - userType: (data.userTypes || []).join(','), - sellingPoints: data.sellingPoints || '', - reviewTotal: data.targetTotal || data.total || 10, - useEmoji: data.useEmoji !== false, - reviewText: firstReview?.reviewContent || '', - generationId: data.generationId || '', - reviewRecordId: firstReview?.reviewRecordId || '', - buyerShowStyle: firstReview?.buyerShowStyle || '真实', - prompt: firstReview?.buyerShowPrompt || '', - }); - setHistoryOpen(false); - setCreateOpen(true); - message.success('历史记录已载入'); - } catch (error) { - message.error(error.response?.data?.error || error.message || '历史记录载入失败'); - } finally { - setHistoryLoading(false); - } - }; - - const openCreateDrawer = () => { - form.resetFields(); - setProductFiles([]); - setSceneFiles([]); - setGenerationResult(null); - setSelectedReviewId(null); - setCurrentJob(null); - setCreateOpen(true); - }; - - const selectedReview = useMemo(() => ( - generationResult?.reviews?.find((review) => review.reviewRecordId === selectedReviewId) || null - ), [generationResult, selectedReviewId]); - - const generateReviews = async () => { - try { - const values = await form.validateFields([ - 'productName', 'scene', 'userType', 'sellingPoints', 'reviewTotal', 'useEmoji', - ]); - if (!productFiles.length) { - message.warning('请先上传商品参考图'); - return; - } - setReviewsLoading(true); - const response = await generateAiBuyerShowReviews({ values, productImages: productFiles }); - const data = response.data || null; - const firstReview = data?.reviews?.[0] || null; - if (data?.productImageUrls?.length) { - setProductFiles(toRemoteUploadList(data.productImageUrls, 'product-reference')); - } - setGenerationResult(data); - setSelectedReviewId(firstReview?.reviewRecordId || null); - form.setFieldsValue({ - reviewText: firstReview?.reviewContent || '', - generationId: firstReview?.generationId || '', - reviewRecordId: firstReview?.reviewRecordId || '', - prompt: '', - }); - message.success(`已生成并保存 ${data?.reviews?.length || 0} 条攻心评价`); - } catch (error) { - if (!error?.errorFields) { - message.error(error.response?.data?.error || error.message || '攻心评价生成失败'); - } - } finally { - setReviewsLoading(false); - } - }; - - const selectReview = (review) => { - setSelectedReviewId(review.reviewRecordId); - form.setFieldsValue({ - reviewText: review.reviewContent, - generationId: review.generationId, - reviewRecordId: review.reviewRecordId, - buyerShowStyle: review.buyerShowStyle || form.getFieldValue('buyerShowStyle') || '真实', - prompt: review.buyerShowPrompt || '', - }); - setSceneFiles(toRemoteUploadList( - review.buyerShowSceneReferenceImageUrls || [], - 'scene-reference' - )); - }; - - const previewPrompt = async () => { - try { - setPromptLoading(true); - const values = form.getFieldsValue(); - const response = await previewAiBuyerShowPrompt(selectedReview ? { - review: selectedReview, - analysis: generationResult?.imageAnalysis || {}, - buyerShowStyle: values.buyerShowStyle, - } : values); - form.setFieldValue('prompt', response.data?.prompt || ''); - } catch (error) { - message.error(error.response?.data?.error || '提示词生成失败'); - } finally { - setPromptLoading(false); - } - }; - - const submitJob = async () => { - try { - const values = await form.validateFields(); - if (!selectedReview || !generationResult?.generationId) { - message.warning('请先生成并选择一条攻心评价'); - return; - } - setSubmitting(true); - const productImageUrls = productFiles.map((file) => file.url).filter(Boolean); - const sceneReferenceImageUrls = sceneFiles.map((file) => file.url).filter(Boolean); - const response = await createAiBuyerShowJob({ - values: { - ...values, - imageCount: 5, - productImageUrls: [...new Set(productImageUrls)].join('\n'), - sceneReferenceImageUrls: [...new Set(sceneReferenceImageUrls)].join('\n'), - }, - productImages: [], - sceneReferenceImages: sceneFiles, - }); - const job = response.data || null; - setCurrentJob(job); - message.success(job?.queueAhead > 0 - ? `任务已创建,前面还有 ${job.queueAhead} 个任务` - : '任务已创建'); - loadRuntime(); - } catch (error) { - if (!error?.errorFields) { - message.error(error.response?.data?.error || error.message || '任务创建失败'); - } - } finally { - setSubmitting(false); - } - }; - - const previewImages = useMemo(() => ( - (previewRecord?.images || []).map((image) => ({ - src: getOriginalImageUrl(image), - thumb: getImageUrl(image), - })).filter((image) => image.src) - ), [previewRecord]); - - const selectedJobs = useMemo(() => { - const selected = new Set(selectedJobKeys); - return jobs.filter((record) => selected.has(getRecordKey(record))); - }, [jobs, selectedJobKeys]); - - const allLoadedSelected = jobs.length > 0 && selectedJobs.length === jobs.length; - - const toggleJobSelection = (record, checked) => { - const key = getRecordKey(record); - setSelectedJobKeys((previous) => (checked - ? [...new Set([...previous, key])] - : previous.filter((item) => item !== key))); - }; - - const toggleAllLoaded = (checked) => { - setSelectedJobKeys(checked ? jobs.map(getRecordKey) : []); - }; - - const downloadSelectedJobs = async () => { - if (!selectedJobs.length) { - message.warning('请先勾选需要下载的买家秀'); - return; - } - - try { - setDownloadLoading(true); - const zip = new JSZip(); - let downloaded = 0; - let failed = 0; - for (let recordIndex = 0; recordIndex < selectedJobs.length; recordIndex += 1) { - const record = selectedJobs[recordIndex]; - const folderName = safeFileName( - `${record.productName || '未填写SPU'}_评价${Number(record.reviewIndex || 0) + 1}_${record.reviewRecordId}` - ); - const folder = zip.folder(folderName); - folder.file('评价内容.txt', record.reviewText || ''); - const failedUrls = []; - for (let imageIndex = 0; imageIndex < (record.images || []).length; imageIndex += 1) { - const imageUrl = getOriginalImageUrl(record.images[imageIndex]); - if (!imageUrl) continue; - try { - const token = localStorage.getItem('token'); - const response = await fetch(`/api/ai-buyer-show/image-proxy?url=${encodeURIComponent(imageUrl)}`, { - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const blob = await response.blob(); - const extension = resolveImageExtension( - imageUrl, - blob.type || response.headers.get('content-type') - ); - folder.file(`${imageIndex + 1}.${extension}`, blob); - downloaded += 1; - } catch { - failed += 1; - failedUrls.push(imageUrl); - } - } - if (failedUrls.length) folder.file('下载失败的图片地址.txt', failedUrls.join('\r\n')); - } - const blob = await zip.generateAsync({ type: 'blob' }); - downloadBlob(`AI买家秀_${Date.now()}.zip`, blob); - if (failed) { - message.warning(`已下载 ${downloaded} 张图片,${failed} 张失败,失败地址已写入压缩包`); - } else { - message.success(`已下载 ${selectedJobs.length} 套,共 ${downloaded} 张图片`); - } - } catch (error) { - message.error(error.message || '买家秀打包下载失败'); - } finally { - setDownloadLoading(false); - } - }; - - const currentStatus = String(currentJob?.status || '').toLowerCase(); - const isCurrentJobActive = ACTIVE_STATUSES.has(currentStatus); - - return ( - -
-
-

AI买家秀素材库

- {spuKeyword ? `“${spuKeyword}” 共 ${jobsTotal} 套` : `共 ${jobsTotal} 套已生成素材`} -
-
- } - placeholder="搜索 SPU" - enterButton="查询" - onChange={(event) => setSearchInput(event.target.value)} - onSearch={handleSearch} - /> - - -
-
- - {!databaseOk && !runtimeLoading ? ( - - ) : null} - -
- {jobs.length ? ( -
- 0 && !allLoadedSelected} - onChange={(event) => toggleAllLoaded(event.target.checked)} - > - 全选当前已加载 - - 已选 {selectedJobs.length} 套 - -
- ) : null} - {jobsLoading ? ( -
- ) : jobs.length ? ( - <> -
- {jobs.map((record) => { - const cover = record.images?.[0]; - const imageCount = record.images?.length || 0; - return ( -
- event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - role="presentation" - > - toggleJobSelection(record, event.target.checked)} - /> - - -
-
- {record.productName || '未填写 SPU'} - 第 {record.reviewIndex + 1} 条 -
- {record.reviewText ? ( -

{record.reviewText}

- ) : null} -
- {formatDateTime(record.completedAt || record.updatedAt)} - {record.operatorName ? {record.operatorName} : null} -
-
-
- ); - })} -
-
- {loadingMore ? : null} - {!hasMore ? 已加载全部 {jobsTotal} 套素材 : null} -
- - ) : ( -
- - {spuKeyword ? : null} - -
- )} -
- - setCreateOpen(false)} - className="ai-buyer-show-create-drawer" - extra={queue ? ( - - 队列:{queue.runningTotal || 0} 生图中 / {queue.pendingTotal || 0} 等待 - - ) : null} - footer={( -
- - {generationResult?.reviews?.length ? ( - - ) : ( - - )} -
- )} - > - {currentJob ? ( -
-
- {currentJob.productName || form.getFieldValue('productName') || '当前任务'} - {getStatusTag(currentJob.status)} -
- - {currentJob.error ? : null} -
- ) : null} - -
-
-

商品资料

-
- - - - - - - - - - - - -
-
- - - - -
- -
-

参考图片

-
- - false} - fileList={productFiles} - onChange={(info) => setProductFiles(toUploadList(info, 5))} - > - {productFiles.length >= 5 ? null : } - - - - false} - fileList={sceneFiles} - onChange={(info) => setSceneFiles(toUploadList(info, 4))} - > - {sceneFiles.length >= 4 ? null : } - - -
-
- - {generationResult?.reviews?.length ? ( -
-
-

选择生图评价

- {generationResult.reviews.length} 条已保存 -
-
- {generationResult.reviews.map((review) => { - const buyerShowImages = getReviewImages(review); - return ( -
selectReview(review)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - selectReview(review); - } - }} - > - - - - 第 {review.reviewOrder} 条 - {buyerShowImages.length ? ( - = 5 ? 'success' : 'warning'}> - 已生成 {buyerShowImages.length} 张 - - ) : getStatusTag(review.buyerShowStatus)} - - {[review.userType, review.scene, review.sellingPoint].filter(Boolean).join(' / ')} -

{review.reviewContent}

- {buyerShowImages.length ? ( -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - - {buyerShowImages.map((image, imageIndex) => ( - {`第 - ))} - -
- ) : ( - 暂无买家秀 - )} -
-
- ); - })} -
-
- ) : null} - -
-

生成设置

-
- - - - - } - placeholder="搜索 SPU / 场景 / 卖点 / 操作人" - value={historyKeyword} - onChange={(event) => setHistoryKeyword(event.target.value)} - onPressEnter={() => loadHistoryRows({ current: 1 })} - /> - -
- `共 ${total} 条`, - }} - onChange={(pagination) => loadHistoryRows(pagination)} - columns={[ - { - title: '生成时间', - dataIndex: 'createdAt', - width: 150, - render: (value) => formatDateTime(value), - }, - { - title: 'SPU', - dataIndex: 'spu', - width: 150, - render: (value) => value || '-', - }, - { - title: '场景', - dataIndex: 'scene', - width: 110, - render: (value) => value || '-', - }, - { - title: '卖点', - dataIndex: 'sellingPoints', - ellipsis: true, - }, - { - title: '评价', - dataIndex: 'reviewCount', - width: 80, - render: (value) => {value || 0} 条, - }, - { - title: '已生图', - dataIndex: 'buyerShowCount', - width: 90, - render: (value) => {value || 0} 条, - }, - { - title: '操作人', - dataIndex: 'operatorName', - width: 110, - render: (value) => value || '-', - }, - { - title: '操作', - key: 'action', - width: 76, - fixed: 'right', - render: (_, record) => ( - - ), - }, - ]} - /> - - - setPreviewRecord(null)} - className="ai-buyer-show-preview-modal" - > - {previewRecord ? ( - <> -
- {previewImages.length} 张图片 - {formatDateTime(previewRecord.completedAt || previewRecord.updatedAt)} - {previewRecord.operatorName ? {previewRecord.operatorName} : null} -
- {previewRecord.reviewText ? ( -

{previewRecord.reviewText}

- ) : null} - -
- {previewImages.map((image, index) => ( - - ))} -
-
- - ) : null} -
- - ); -}; - -export default AiBuyerShow; diff --git a/frontend/src/pages/AiBuyerShow/style.css b/frontend/src/pages/AiBuyerShow/style.css deleted file mode 100644 index 7ea0c7e..0000000 --- a/frontend/src/pages/AiBuyerShow/style.css +++ /dev/null @@ -1,688 +0,0 @@ -.ai-buyer-show-page { - min-height: 100%; - padding: 16px 18px 28px; - background: var(--sycm-bg-page-surface, #f5f7fa); -} - -.ai-buyer-show-header { - position: sticky; - z-index: 8; - top: 0; - display: flex; - min-height: 58px; - align-items: center; - justify-content: space-between; - gap: 16px; - margin: -16px -18px 16px; - border-bottom: 1px solid var(--sycm-border-light, #e5e7eb); - padding: 10px 18px; - background: rgba(255, 255, 255, 0.96); - box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); - backdrop-filter: blur(10px); -} - -.ai-buyer-show-header__copy { - display: flex; - min-width: 0; - align-items: baseline; - gap: 10px; -} - -.ai-buyer-show-header__copy h1 { - margin: 0; - color: var(--sycm-text-strong, #111827); - font-size: 18px; - font-weight: 650; - letter-spacing: 0; - line-height: 28px; -} - -.ai-buyer-show-header__copy span, -.ai-buyer-show-queue { - overflow: hidden; - color: var(--sycm-text-tertiary, #8a94a6); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ai-buyer-show-header__actions { - display: flex; - flex: 0 0 auto; - align-items: center; - gap: 8px; -} - -.ai-buyer-show-search { - width: min(340px, 30vw); -} - -.ai-buyer-show-library { - min-height: calc(100vh - 130px); -} - -.ai-buyer-show-selection-bar { - position: sticky; - z-index: 7; - top: 58px; - display: flex; - min-height: 44px; - align-items: center; - gap: 14px; - margin: -4px 0 12px; - border: 1px solid var(--sycm-border-light, #e5e7eb); - border-radius: 6px; - padding: 6px 10px; - background: rgba(255, 255, 255, 0.96); - box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); - backdrop-filter: blur(8px); -} - -.ai-buyer-show-selection-bar > span { - color: var(--sycm-text-tertiary, #8a94a6); - font-size: 12px; -} - -.ai-buyer-show-selection-bar .ant-btn { - margin-left: auto; -} - -.ai-buyer-show-state { - display: grid; - min-height: 420px; - place-items: center; -} - -.ai-buyer-show-waterfall { - column-count: 5; - column-gap: 12px; -} - -.ai-buyer-show-card { - position: relative; - display: inline-block; - overflow: hidden; - width: 100%; - margin: 0 0 12px; - break-inside: avoid; - border: 1px solid rgba(15, 23, 42, 0.07); - border-radius: 6px; - background: #fff; - box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); - vertical-align: top; - transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; -} - -.ai-buyer-show-card.is-selected { - border-color: #1677ff; - box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.14); -} - -.ai-buyer-show-card__select { - position: absolute; - z-index: 4; - top: 8px; - left: 8px; - display: grid; - width: 28px; - height: 28px; - border-radius: 4px; - background: rgba(255, 255, 255, 0.94); - box-shadow: 0 1px 5px rgba(15, 23, 42, 0.18); - place-items: center; -} - -.ai-buyer-show-card__select .ant-checkbox-wrapper { - height: 16px; - line-height: 16px; -} - -.ai-buyer-show-card:hover { - border-color: rgba(22, 119, 255, 0.28); - box-shadow: 0 8px 20px rgba(15, 23, 42, 0.09); - transform: translateY(-2px); -} - -.ai-buyer-show-card__preview { - position: relative; - display: block; - overflow: hidden; - width: 100%; - min-height: 140px; - border: 0; - padding: 0; - background: #eef1f5; - cursor: zoom-in; -} - -.ai-buyer-show-card__preview img { - display: block; - width: 100%; - height: auto; - min-height: 140px; - max-height: 360px; - object-fit: cover; - transition: transform 220ms ease; -} - -.ai-buyer-show-card:hover .ai-buyer-show-card__preview img { - transform: scale(1.015); -} - -.ai-buyer-show-card__count, -.ai-buyer-show-card__status { - position: absolute; - top: 8px; - display: inline-flex; - min-height: 24px; - align-items: center; - gap: 4px; - border-radius: 4px; - padding: 3px 7px; - background: rgba(17, 24, 39, 0.76); - color: #fff; - font-size: 12px; - line-height: 18px; - backdrop-filter: blur(5px); -} - -.ai-buyer-show-card__count { - right: 8px; -} - -.ai-buyer-show-card__status { - left: 44px; - background: rgba(212, 136, 6, 0.88); -} - -.ai-buyer-show-card__body { - padding: 10px 11px 11px; -} - -.ai-buyer-show-card__title { - display: flex; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.ai-buyer-show-card__title strong { - overflow: hidden; - min-width: 0; - color: var(--sycm-text-strong, #111827); - font-size: 13px; - font-weight: 650; - line-height: 20px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ai-buyer-show-card__title span { - flex: 0 0 auto; - color: var(--sycm-text-tertiary, #8a94a6); - font-size: 11px; -} - -.ai-buyer-show-card__body p { - display: -webkit-box; - overflow: hidden; - margin: 6px 0 9px; - color: var(--sycm-text-secondary, #596579); - font-size: 12px; - line-height: 18px; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - -.ai-buyer-show-card__meta { - display: flex; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: 7px; - color: var(--sycm-text-tertiary, #8a94a6); - font-size: 11px; -} - -.ai-buyer-show-card__meta span { - display: inline-flex; - overflow: hidden; - min-width: 0; - align-items: center; - gap: 4px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ai-buyer-show-load-more { - display: flex; - min-height: 50px; - align-items: center; - justify-content: center; - color: var(--sycm-text-tertiary, #8a94a6); - font-size: 12px; -} - -.ai-buyer-show-create-drawer .ant-drawer-header { - padding: 14px 18px; -} - -.ai-buyer-show-create-drawer .ant-drawer-body { - padding: 14px 18px 24px; - background: #f6f8fb; -} - -.ai-buyer-show-create-drawer .ant-drawer-footer { - padding: 10px 18px; -} - -.ai-buyer-show-drawer-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; -} - -.ai-buyer-show-current-job { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 12px; - border: 1px solid #d9e8ff; - border-radius: 6px; - padding: 10px 12px; - background: #f3f8ff; -} - -.ai-buyer-show-current-job > div:first-child { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.ai-buyer-show-current-job strong { - overflow: hidden; - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ai-buyer-show-form-section { - margin-bottom: 12px; - border: 1px solid var(--sycm-border-light, #e5e7eb); - border-radius: 6px; - padding: 12px; - background: #fff; -} - -.ai-buyer-show-form-section:last-child { - margin-bottom: 0; -} - -.ai-buyer-show-form-section h3 { - margin: 0 0 11px; - color: var(--sycm-text-strong, #111827); - font-size: 13px; - font-weight: 650; - line-height: 20px; -} - -.ai-buyer-show-review-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; -} - -.ai-buyer-show-review-heading h3 { - margin: 0; -} - -.ai-buyer-show-review-heading span { - color: var(--sycm-text-tertiary, #8a94a6); - font-size: 12px; -} - -.ai-buyer-show-review-list { - display: flex; - max-height: 330px; - flex-direction: column; - gap: 8px; - overflow-y: auto; -} - -.ai-buyer-show-review-item { - display: grid; - width: 100%; - grid-template-columns: 16px minmax(0, 1fr); - gap: 9px; - border: 1px solid var(--sycm-border-light, #e5e7eb); - border-radius: 6px; - padding: 9px 10px; - background: #fff; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} - -.ai-buyer-show-review-item:hover { - border-color: #91caff; -} - -.ai-buyer-show-review-item.is-selected { - border-color: #1677ff; - background: #f3f8ff; - box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.08); -} - -.ai-buyer-show-review-radio { - width: 14px; - height: 14px; - margin-top: 3px; - border: 1px solid #b8c0cc; - border-radius: 50%; - background: #fff; -} - -.ai-buyer-show-review-item.is-selected .ai-buyer-show-review-radio { - border: 4px solid #1677ff; -} - -.ai-buyer-show-review-item > span:last-child { - min-width: 0; -} - -.ai-buyer-show-review-item strong { - color: var(--sycm-text-strong, #111827); - font-size: 12px; -} - -.ai-buyer-show-review-item__heading { - display: flex; - min-height: 22px; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.ai-buyer-show-review-item__heading .ant-tag { - margin-inline-end: 0; - font-size: 11px; - line-height: 18px; -} - -.ai-buyer-show-review-item em { - display: block; - overflow: hidden; - margin-top: 2px; - color: var(--sycm-text-tertiary, #8a94a6); - font-size: 11px; - font-style: normal; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ai-buyer-show-review-item p { - margin: 5px 0 0; - color: var(--sycm-text-secondary, #596579); - font-size: 12px; - line-height: 18px; -} - -.ai-buyer-show-review-images { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 6px; - margin-top: 8px; -} - -.ai-buyer-show-review-images .ant-image { - display: block; - overflow: hidden; - width: 100%; - border: 1px solid var(--sycm-border-light, #e5e7eb); - border-radius: 4px; - background: #eef1f5; -} - -.ai-buyer-show-review-images img { - display: block; - width: 100%; - aspect-ratio: 4 / 3; - object-fit: cover; -} - -.ai-buyer-show-review-images__empty { - display: block; - margin-top: 6px; - color: var(--sycm-text-tertiary, #8a94a6); - font-size: 11px; -} - -.ai-buyer-show-form-section .ant-form-item { - margin-bottom: 11px; -} - -.ai-buyer-show-form-section .ant-form-item:last-child { - margin-bottom: 0; -} - -.ai-buyer-show-form-section .ant-form-item-label { - padding-bottom: 4px; -} - -.ai-buyer-show-form-section .ant-form-item-label > label { - height: 20px; - color: var(--sycm-text-secondary, #596579); - font-size: 12px; -} - -.ai-buyer-show-form-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 12px; -} - -.ai-buyer-show-settings-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0 10px; -} - -.ai-buyer-show-fixed-setting { - min-width: 0; - padding-bottom: 11px; -} - -.ai-buyer-show-fixed-setting > span { - display: block; - height: 24px; - color: var(--sycm-text-secondary, #596579); - font-size: 12px; - line-height: 20px; -} - -.ai-buyer-show-fixed-setting > strong { - display: flex; - height: 32px; - align-items: center; - gap: 6px; - border: 1px solid var(--sycm-border-light, #e5e7eb); - border-radius: 6px; - padding: 0 10px; - background: #f7f9fc; - color: var(--sycm-text-strong, #111827); - font-size: 12px; - font-weight: 600; -} - -.ai-buyer-show-upload-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.ai-buyer-show-upload-grid .ant-form-item { - min-width: 0; - border: 1px dashed #d7dce5; - border-radius: 6px; - padding: 9px; - background: #fafbfd; -} - -.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload-list { - gap: 6px; -} - -.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload-list-picture-card .ant-upload-list-item-container, -.ai-buyer-show-upload-grid .ant-upload-wrapper .ant-upload.ant-upload-select { - width: 66px; - height: 66px; - margin: 0; -} - -.ai-buyer-show-preview-modal .ant-modal-body { - max-height: min(76vh, 760px); - overflow-y: auto; -} - -.ai-buyer-show-history-toolbar { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 14px; -} - -.ai-buyer-show-history-toolbar .ant-input-affix-wrapper { - width: min(380px, 100%); -} - -.ai-buyer-show-preview-meta { - display: flex; - flex-wrap: wrap; - gap: 14px; - margin-bottom: 8px; - color: var(--sycm-text-tertiary, #8a94a6); - font-size: 12px; -} - -.ai-buyer-show-preview-review { - margin: 0 0 12px; - border-left: 3px solid #d7e6ff; - padding: 7px 10px; - background: #f7f9fc; - color: var(--sycm-text-secondary, #596579); - font-size: 12px; - line-height: 19px; -} - -.ai-buyer-show-preview-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.ai-buyer-show-preview-grid .ant-image { - display: block; - overflow: hidden; - width: 100%; - border: 1px solid var(--sycm-border-light, #e5e7eb); - border-radius: 5px; - background: #eef1f5; -} - -.ai-buyer-show-preview-grid img { - display: block; - width: 100%; - aspect-ratio: 4 / 3; - object-fit: contain; -} - -@media (max-width: 1600px) { - .ai-buyer-show-waterfall { - column-count: 4; - } -} - -@media (max-width: 1180px) { - .ai-buyer-show-waterfall { - column-count: 3; - } -} - -@media (max-width: 820px) { - .ai-buyer-show-page { - padding: 12px; - } - - .ai-buyer-show-header { - position: static; - align-items: stretch; - flex-direction: column; - margin: -12px -12px 12px; - padding: 11px 12px; - } - - .ai-buyer-show-header__actions { - flex-wrap: wrap; - } - - .ai-buyer-show-search { - width: 100%; - } - - .ai-buyer-show-header__actions > .ant-btn:last-child { - flex: 1 1 auto; - } - - .ai-buyer-show-selection-bar { - position: static; - } - - .ai-buyer-show-history-drawer .ant-drawer-content-wrapper { - width: 100% !important; - } - - .ai-buyer-show-waterfall { - column-count: 2; - column-gap: 10px; - } - - .ai-buyer-show-card { - margin-bottom: 10px; - } - - .ai-buyer-show-create-drawer .ant-drawer-content-wrapper { - width: 100% !important; - } -} - -@media (max-width: 560px) { - .ai-buyer-show-header__copy { - align-items: flex-start; - flex-direction: column; - gap: 0; - } - - .ai-buyer-show-waterfall { - column-count: 1; - } - - .ai-buyer-show-form-grid, - .ai-buyer-show-upload-grid, - .ai-buyer-show-preview-grid { - grid-template-columns: 1fr; - } - - .ai-buyer-show-settings-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .ai-buyer-show-review-images { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } -} diff --git a/frontend/src/pages/CustomerServiceQuality/AiQualityDrawer.jsx b/frontend/src/pages/CustomerServiceQuality/AiQualityDrawer.jsx deleted file mode 100644 index 5f676fa..0000000 --- a/frontend/src/pages/CustomerServiceQuality/AiQualityDrawer.jsx +++ /dev/null @@ -1,378 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, Button, DatePicker, Drawer, Image, Progress, Spin, Tag, Typography, message } from 'antd'; -import { SafetyCertificateOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import { - getCsQualityAiAnalysisJob, - getCsQualityProductProfile, - startCsQualityAiAnalysis, -} from '../../api/csQuality'; -import AiQualityResult from './AiQualityResult'; - -const { RangePicker } = DatePicker; -const { Text, Title } = Typography; -const ACTIVE_ANALYSIS_STATUSES = new Set(['queued', 'running']); -const ACTIVE_JOB_STORAGE_PREFIX = 'cs-quality-active-job:'; -const PROGRESS_HINTS = { - queued: ['正在分配分析任务', '任务状态会持续同步到当前页面'], - preparing: ['正在确认商品信息和聊天周期', '正在检查本次质检所需的数据'], - loading: ['正在统计客户数量与消息数量', '正在读取所选周期内的完整聊天记录'], - grouping: ['正在按客户归整连续对话', '同一客户的上下文会保留在同一分析批次中'], - batching: ['正在根据数据量安排分析批次', '批次准备完成后会逐批更新进度'], - analyzing: [ - '正在识别买家诉求、高频问题和对应证据', - '正在逐批检查售前咨询与售后反馈', - '数据量较大时可能需要数分钟,任务仍会在后台继续运行', - '完成后的结果会自动展示,并同步保存到质检历史记录', - ], - merging: ['正在合并各批次发现的问题', '正在去重问题分类与聊天证据'], - validating: ['正在复核问题分类和影响客户数', '正在校验诊断结果的完整性'], - saving: ['结果已生成,正在写入质检历史记录', '保存完成后页面会自动展示诊断结果'], -}; - -function unwrapResponse(response) { - return response?.data?.data || response?.data || response; -} - -function sanitizeUserMessage(value, fallback) { - return String(value || fallback || '').trim(); -} - -function activeJobStorageKey(goodsId) { - return `${ACTIVE_JOB_STORAGE_PREFIX}${goodsId || ''}`; -} - -function readActiveJob(goodsId) { - try { - const saved = JSON.parse(window.localStorage.getItem(activeJobStorageKey(goodsId)) || 'null'); - if (!saved?.analysisId || String(saved.goodsId) !== String(goodsId)) return null; - return saved; - } catch { - return null; - } -} - -function persistActiveJob(goodsId, job) { - if (!goodsId || !job?.analysisId) return; - const range = job.range || {}; - try { - window.localStorage.setItem(activeJobStorageKey(goodsId), JSON.stringify({ - ...job, - goodsId: String(goodsId), - startDate: range.startDate || job.startDate, - endDate: range.endDate || job.endDate, - })); - } catch { - // Progress polling still works when browser storage is unavailable. - } -} - -function clearActiveJob(goodsId, analysisId) { - try { - const key = activeJobStorageKey(goodsId); - const saved = readActiveJob(goodsId); - if (!saved || !analysisId || saved.analysisId === analysisId) window.localStorage.removeItem(key); - } catch { - // Ignore storage restrictions after the server-side task has completed. - } -} - -function formatElapsed(totalSeconds) { - const seconds = Math.max(0, Number(totalSeconds) || 0); - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const remainder = Math.floor(seconds % 60); - if (hours) return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`; - return `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`; -} - -function getDefaultRange() { - const endDate = dayjs().subtract(1, 'day'); - return [endDate.subtract(29, 'day'), endDate]; -} - -function normalizeRange(value) { - if (!Array.isArray(value) || value.length !== 2 || !value[0] || !value[1]) return getDefaultRange(); - if (Math.abs(value[1].startOf('day').diff(value[0].startOf('day'), 'day')) > 29) return [value[1].subtract(29, 'day'), value[1]]; - return value; -} - -function formatNumber(value, digits = 0) { - const number = Number(value); - if (!Number.isFinite(number)) return '--'; - return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); -} - -const AiQualityDrawer = ({ open, goods, onClose, onCompleted }) => { - const [range, setRange] = useState(getDefaultRange); - const [profile, setProfile] = useState(null); - const [profileLoading, setProfileLoading] = useState(false); - const [analysisJob, setAnalysisJob] = useState(null); - const [analysisResult, setAnalysisResult] = useState(null); - const [analysisError, setAnalysisError] = useState(''); - const [pollWarning, setPollWarning] = useState(''); - const [hintIndex, setHintIndex] = useState(0); - const [elapsedSeconds, setElapsedSeconds] = useState(0); - const onCompletedRef = useRef(onCompleted); - const completedAnalysisRef = useRef(''); - - useEffect(() => { onCompletedRef.current = onCompleted; }, [onCompleted]); - - const analysisLoading = ACTIVE_ANALYSIS_STATUSES.has(analysisJob?.status); - - const params = useMemo(() => ({ - startDate: range?.[0]?.format('YYYY-MM-DD'), - endDate: range?.[1]?.format('YYYY-MM-DD'), - }), [range]); - - const loadProfile = useCallback(async () => { - if (!open || !goods?.goodsId || !params.startDate || !params.endDate) return; - setProfileLoading(true); - try { - const response = await getCsQualityProductProfile(goods.goodsId, params); - const payload = response?.data?.data || response?.data || response; - setProfile(payload); - } catch (error) { - message.error(error.response?.data?.error || error.message || '加载商品表现失败'); - setProfile(null); - } finally { - setProfileLoading(false); - } - }, [open, goods?.goodsId, params]); - - useEffect(() => { - if (!open) return; - const savedJob = readActiveJob(goods?.goodsId); - const savedRange = savedJob?.startDate && savedJob?.endDate - ? [dayjs(savedJob.startDate), dayjs(savedJob.endDate)] - : null; - setRange(savedRange?.every((item) => item.isValid()) ? savedRange : getDefaultRange()); - setAnalysisJob(savedJob); - setAnalysisResult(null); - setAnalysisError(''); - setPollWarning(''); - completedAnalysisRef.current = ''; - }, [open, goods?.goodsId]); - - useEffect(() => { loadProfile(); }, [loadProfile]); - - useEffect(() => { - if (!analysisLoading) return undefined; - const startedAt = Date.parse(analysisJob?.createdAt || '') || Date.now(); - const updateElapsed = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))); - updateElapsed(); - const timer = window.setInterval(updateElapsed, 1000); - return () => window.clearInterval(timer); - }, [analysisLoading, analysisJob?.createdAt]); - - useEffect(() => { - if (!analysisLoading) return undefined; - setHintIndex(0); - const timer = window.setInterval(() => setHintIndex((current) => current + 1), 8000); - return () => window.clearInterval(timer); - }, [analysisLoading, analysisJob?.progress?.stage]); - - useEffect(() => { - const analysisId = analysisJob?.analysisId; - if (!analysisId || !ACTIVE_ANALYSIS_STATUSES.has(analysisJob?.status)) return undefined; - let cancelled = false; - let timer = null; - - const poll = async () => { - let nextStatus = analysisJob.status; - try { - const payload = unwrapResponse(await getCsQualityAiAnalysisJob(analysisId)); - if (cancelled || !payload) return; - nextStatus = payload.status; - setAnalysisJob(payload); - setPollWarning(''); - if (ACTIVE_ANALYSIS_STATUSES.has(payload.status)) { - persistActiveJob(goods?.goodsId, payload); - } else { - clearActiveJob(goods?.goodsId, analysisId); - } - - if (payload.status === 'succeeded' && payload.result && completedAnalysisRef.current !== analysisId) { - completedAnalysisRef.current = analysisId; - setAnalysisResult(payload.result); - setProfile((current) => current || { - product: payload.result.product, - metrics: payload.result.metrics, - chatSummary: payload.result.chatSummary, - }); - setAnalysisError(''); - message.success('AI质检完成'); - onCompletedRef.current?.(payload.result); - } else if (payload.status === 'succeeded' && !payload.result) { - setAnalysisError('质检任务已完成,但结果暂时无法读取,请在质检历史记录中查看'); - } else if (payload.status === 'failed') { - setAnalysisError(sanitizeUserMessage(payload.errorMessage, 'AI质检未完成,请稍后重试')); - } - } catch { - if (!cancelled) setPollWarning('进度同步暂时中断,系统正在自动重试,后台任务不会停止'); - } - - if (!cancelled && ACTIVE_ANALYSIS_STATUSES.has(nextStatus)) { - timer = window.setTimeout(poll, 2500); - } - }; - - timer = window.setTimeout(poll, 700); - return () => { - cancelled = true; - if (timer) window.clearTimeout(timer); - }; - }, [analysisJob?.analysisId, analysisJob?.status, goods?.goodsId]); - - const handleAnalyze = async () => { - if (!goods?.goodsId || !params.startDate || !params.endDate) return; - const createdAt = new Date().toISOString(); - setAnalysisJob({ - status: 'queued', - createdAt, - range: params, - progress: { stage: 'queued', current: 0, total: 0, percent: 0, message: '正在创建质检任务' }, - }); - setAnalysisResult(null); - setAnalysisError(''); - setPollWarning(''); - completedAnalysisRef.current = ''; - try { - const response = await startCsQualityAiAnalysis({ - goodsId: goods.goodsId, - goodsName: goods.goodsName, - shopName: goods.shopName, - hdThumbUrl: goods.hdThumbUrl, - ...params, - }); - const payload = unwrapResponse(response); - setAnalysisJob(payload); - persistActiveJob(goods.goodsId, payload); - } catch (error) { - setAnalysisJob(null); - setAnalysisError(sanitizeUserMessage(error.response?.data?.error || error.message, '创建AI质检任务失败')); - } - }; - - const product = { ...goods, ...(profile?.product || {}) }; - const metrics = profile?.metrics || {}; - const chatSummary = profile?.chatSummary || {}; - const progress = analysisJob?.progress || {}; - const progressPercent = Math.min(100, Math.max(0, Number(progress.percent || 0))); - const progressHints = PROGRESS_HINTS[progress.stage] || PROGRESS_HINTS.analyzing; - const progressHint = pollWarning || progressHints[hintIndex % progressHints.length]; - const batchProgress = Number(progress.total) > 0 - ? `已完成 ${Number(progress.current) || 0} / ${Number(progress.total)} 批` - : '正在准备分析数据'; - const metricItems = [ - ['聊天客户', formatNumber(chatSummary.userCount)], - ['聊天消息', formatNumber(chatSummary.messageCount)], - ['商品访客', formatNumber(metrics.goodsUv)], - ['商品浏览', formatNumber(metrics.goodsPv)], - ['支付金额', `¥${formatNumber(metrics.payOrdrAmt, 2)}`], - ['支付件数', formatNumber(metrics.payOrdrGoodsQty)], - ['商品转化率', `${formatNumber(metrics.goodsVcr, 2)}%`], - ['支付转化率', `${formatNumber(metrics.payOrdrRto, 2)}%`], - ]; - - return ( - } loading={analysisLoading} onClick={handleAnalyze}> - {analysisLoading ? '质检进行中' : '开始AI质检'} - - )} - > - -
- -
- {product.goodsName || goods?.goodsName || '未获取到商品名称'} -
- 商品ID:{goods?.goodsId || '--'} - 所属店铺:{product.shopName || goods?.shopName || '--'} - {(product.cate1Name || product.cate2Name || product.cate3Name) && ( - 类目:{[product.cate1Name, product.cate2Name, product.cate3Name].filter(Boolean).join(' / ')} - )} -
-
-
- -
-
-
质检范围一次性分析,最长支持30天
- { - setRange(normalizeRange(value)); - setAnalysisJob(null); - setAnalysisResult(null); - setAnalysisError(''); - }} - disabledDate={(current, info) => { - if (current?.isAfter(dayjs(), 'day')) return true; - return info?.from ? Math.abs(current.startOf('day').diff(info.from.startOf('day'), 'day')) > 29 : false; - }} - /> -
-
- {metricItems.map(([label, value]) =>
{label}{value}
)} -
-
- {params.startDate} 至 {params.endDate} - 商品表现按所选周期汇总,比例指标按日均值展示。 -
-
-
- -
-
诊断结果
- {analysisLoading && ( -
-
-
- {progress.message || '正在准备分析聊天记录'} - {batchProgress} -
- {progressPercent}% -
- -
- 已用时 {formatElapsed(elapsedSeconds)} - {progressHint} -
-
- )} - {analysisError && ( - - )} - {!analysisLoading && ( - - )} -
-
- ); -}; - -export default AiQualityDrawer; diff --git a/frontend/src/pages/CustomerServiceQuality/AiQualityHistoryDrawer.jsx b/frontend/src/pages/CustomerServiceQuality/AiQualityHistoryDrawer.jsx deleted file mode 100644 index 4ad2644..0000000 --- a/frontend/src/pages/CustomerServiceQuality/AiQualityHistoryDrawer.jsx +++ /dev/null @@ -1,160 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, Drawer, Empty, Image, Input, Pagination, Space, Spin, Table, Tag, Typography, message } from 'antd'; -import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import { getCsQualityAnalysisHistory, getCsQualityAnalysisHistoryDetail } from '../../api/csQuality'; -import AiQualityResult from './AiQualityResult'; - -const { Text, Title } = Typography; -const STATUS_MAP = { - succeeded: { color: 'success', text: '已完成' }, - queued: { color: 'default', text: '排队中' }, - running: { color: 'processing', text: '分析中' }, - failed: { color: 'error', text: '失败' }, -}; - -const AiQualityHistoryDrawer = ({ open, onClose, refreshToken = 0 }) => { - const [loading, setLoading] = useState(false); - const [rows, setRows] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [goodsId, setGoodsId] = useState(''); - const [shopName, setShopName] = useState(''); - const [detailOpen, setDetailOpen] = useState(false); - const [detailLoading, setDetailLoading] = useState(false); - const [detail, setDetail] = useState(null); - - const loadHistory = useCallback(async (nextPage = page) => { - if (!open) return; - setLoading(true); - try { - const response = await getCsQualityAnalysisHistory({ page: nextPage, pageSize: 20, goodsId: goodsId.trim(), shopName: shopName.trim() }); - const payload = response?.data?.data || response?.data || response; - setRows(Array.isArray(payload.rows) ? payload.rows : []); - setTotal(Number(payload.total || 0)); - setPage(Number(payload.page || nextPage)); - } catch (error) { - message.error(error.response?.data?.error || error.message || '加载历史记录失败'); - setRows([]); - } finally { - setLoading(false); - } - }, [open, page, goodsId, shopName]); - - useEffect(() => { if (open) loadHistory(1); }, [open, refreshToken]); // eslint-disable-line react-hooks/exhaustive-deps - - const showDetail = async (record) => { - setDetailOpen(true); - setDetailLoading(true); - setDetail(null); - try { - const response = await getCsQualityAnalysisHistoryDetail(record.id); - setDetail(response?.data?.data || response?.data || response); - } catch (error) { - message.error(error.response?.data?.error || error.message || '加载历史详情失败'); - } finally { - setDetailLoading(false); - } - }; - - const columns = useMemo(() => [ - { - title: '商品', - key: 'goods', - width: 320, - render: (_, record) => ( -
- -
{record.goodsName || record.goodsId}ID:{record.goodsId}{record.shopName || '--'}
-
- ), - }, - { - title: '聊天周期', key: 'period', width: 190, - render: (_, record) =>
{dayjs(record.chatStartDate).format('YYYY-MM-DD')} 至 {dayjs(record.chatEndDate).format('YYYY-MM-DD')}{record.customerCount || 0} 客户 · {record.messageCount || 0} 消息
, - }, - { title: '判断', dataIndex: 'conversationType', width: 90, render: (value) => value ? {value} : '--' }, - { - title: '状态', dataIndex: 'status', width: 90, - render: (value, record) => { - const item = STATUS_MAP[value] || { color: 'default', text: value || '--' }; - const active = value === 'queued' || value === 'running'; - return ( -
- {item.text} - {active && {Math.min(100, Math.max(0, Number(record.progressPercent || 0)))}%} -
- ); - }, - }, - { - title: '操作信息', key: 'operator', width: 180, - render: (_, record) =>
{record.createdByName || '--'}{record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') : '--'}
, - }, - { title: '', key: 'action', width: 52, fixed: 'right', render: (_, record) => }> -
- - setGoodsId(event.target.value)} placeholder="商品ID" allowClear style={{ width: 180 }} onPressEnter={() => loadHistory(1)} /> - setShopName(event.target.value)} placeholder="店铺名称" allowClear style={{ width: 220 }} onPressEnter={() => loadHistory(1)} /> - - -
-
}} - /> - {total > 20 &&
loadHistory(value)} />
} - - - setDetailOpen(false)} destroyOnHidden> - - {detail ? ( - <> -
- -
- {detailProduct.goodsName || detailProduct.goodsId} -
- 商品ID:{detailProduct.goodsId}所属店铺:{detailProduct.shopName || '--'} - 聊天周期:{dayjs(detail.chat_start_date).format('YYYY-MM-DD')} 至 {dayjs(detail.chat_end_date).format('YYYY-MM-DD')} - 分析人:{detail.created_by_name || '--'} -
-
-
- {detail.error_message &&
{detail.error_message}
} - - - ) : } -
-
- - ); -}; - -export default AiQualityHistoryDrawer; diff --git a/frontend/src/pages/CustomerServiceQuality/AiQualityResult.jsx b/frontend/src/pages/CustomerServiceQuality/AiQualityResult.jsx deleted file mode 100644 index ad0334e..0000000 --- a/frontend/src/pages/CustomerServiceQuality/AiQualityResult.jsx +++ /dev/null @@ -1,516 +0,0 @@ -import React, { useMemo, useRef, useState } from 'react'; -import { Alert, Button, Descriptions, Empty, Image, Modal, Spin, Table, Tabs, Tag, Typography } from 'antd'; -import { EyeOutlined } from '@ant-design/icons'; -import { getCsQualityMessages } from '../../api/csQuality'; - -const { Paragraph, Text, Title } = Typography; - -const TYPE_COLOR = { - 售前: 'blue', - 售后: 'orange', - 混合: 'purple', - 无有效咨询: 'default', -}; -const IMAGE_URL_PATTERN = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i; -const SUMMARY_BUTTON_STYLE = { - display: 'flex', - minWidth: 0, - height: 'auto', - flexDirection: 'column', - alignItems: 'flex-start', - gap: 4, - padding: 0, -}; - -function formatNumber(value) { - const number = Number(value); - return Number.isFinite(number) ? number.toLocaleString('zh-CN') : '0'; -} - -function formatConfidence(value) { - return `${Math.round((Number(value) || 0) * 100)}%`; -} - -function getIssueTitle(record) { - return [record?.primary_reason, record?.secondary_reason].filter(Boolean).join(' / ') || '问题明细'; -} - -function getAffectedConversationIds(record) { - const sourceRows = Array.isArray(record?.sourceRows) ? record.sourceRows : []; - const records = [record, ...sourceRows].filter(Boolean); - return Array.from(new Set(records.flatMap((item) => [ - ...(Array.isArray(item.affected_conversation_ids) ? item.affected_conversation_ids : []), - ...(Array.isArray(item.affectedConversationIds) ? item.affectedConversationIds : []), - ...(Array.isArray(item.evidence) ? item.evidence.map((entry) => entry?.conversation_id || entry?.conversationId) : []), - ]).map((value) => String(value || '').trim()).filter(Boolean))); -} - -function groupChatMessages(rows, conversationIds) { - const allowedIds = new Set(conversationIds); - const groups = new Map(); - rows.forEach((row, index) => { - const uid = String(row?.uid || '').trim(); - if (!uid || !allowedIds.has(uid)) return; - if (!groups.has(uid)) { - groups.set(uid, { - uid, - nickname: row.nickname || uid, - csname: row.csname || '', - rows: [], - firstIndex: index, - }); - } - const group = groups.get(uid); - group.nickname = group.nickname || row.nickname || uid; - group.csname = group.csname || row.csname || ''; - group.rows.push(row); - }); - return Array.from(groups.values()).sort((left, right) => left.firstIndex - right.firstIndex); -} - -function withOccurrenceShare(sourceRows) { - const total = sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 0), 0); - return sourceRows.map((row) => ({ - ...row, - occurrence_share: total > 0 ? (Number(row.occurrence_count) || 0) / total : 0, - })); -} - -function aggregateIssueTags(sourceRows) { - const groups = new Map(); - sourceRows.forEach((row) => { - const key = row.primary_reason || '其他问题'; - const group = groups.get(key) || { primary_reason: key, sourceRows: [] }; - group.sourceRows.push(row); - groups.set(key, group); - }); - - const aggregated = Array.from(groups.values()).map((group) => { - const occurrenceCount = group.sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 0), 0); - const affectedCustomerCount = group.sourceRows.reduce((sum, row) => sum + (Number(row.affected_customer_count) || 0), 0); - const consultationTypes = Array.from(new Set(group.sourceRows.map((row) => row.consultation_type).filter(Boolean))); - const confidenceWeight = group.sourceRows.reduce((sum, row) => sum + ((Number(row.confidence) || 0) * (Number(row.occurrence_count) || 1)), 0); - const confidenceBase = group.sourceRows.reduce((sum, row) => sum + (Number(row.occurrence_count) || 1), 0); - const evidence = group.sourceRows.flatMap((row) => Array.isArray(row.evidence) ? row.evidence : []).filter((item, index, list) => ( - list.findIndex((candidate) => `${candidate.conversation_id}|${candidate.message_time}|${candidate.text}` === `${item.conversation_id}|${item.message_time}|${item.text}`) === index - )); - const affectedConversationIds = getAffectedConversationIds({ sourceRows: group.sourceRows }); - return { - ...group, - consultation_type: consultationTypes.length === 1 ? consultationTypes[0] : '混合', - secondary_reason: `${group.sourceRows.length} 个细分问题`, - occurrence_count: occurrenceCount, - affected_customer_count: affectedCustomerCount, - affected_conversation_ids: affectedConversationIds, - confidence: confidenceBase > 0 ? confidenceWeight / confidenceBase : 0, - evidence, - product_improvement_areas: Array.from(new Set(group.sourceRows.flatMap((row) => row.product_improvement_areas || []))), - image_urls: Array.from(new Set(group.sourceRows.flatMap((row) => row.image_urls || []))), - improvement_suggestion: Array.from(new Set(group.sourceRows.map((row) => row.improvement_suggestion).filter(Boolean))).join(';'), - }; - }); - return withOccurrenceShare(aggregated).sort((left, right) => right.occurrence_count - left.occurrence_count); -} - -const AiQualityResult = ({ analysis, chatContext = {} }) => { - const [categoryDrawer, setCategoryDrawer] = useState(null); - const [evidenceRecord, setEvidenceRecord] = useState(null); - const [detailTab, setDetailTab] = useState('all-chats'); - const [relatedChats, setRelatedChats] = useState({ loading: false, rows: [], error: '' }); - const [expandedCustomerIds, setExpandedCustomerIds] = useState([]); - const chatRequestRef = useRef(0); - const rows = Array.isArray(analysis?.results) ? analysis.results : []; - const summary = analysis?.summary || {}; - const activeCategoryDrawer = categoryDrawer?.analysis === analysis ? categoryDrawer : null; - const activeEvidenceRecord = evidenceRecord?.analysis === analysis ? evidenceRecord.record : null; - const relatedConversationIds = useMemo( - () => getAffectedConversationIds(activeEvidenceRecord), - [activeEvidenceRecord] - ); - const groupedRelatedChats = useMemo( - () => groupChatMessages(relatedChats.rows, relatedConversationIds), - [relatedChats.rows, relatedConversationIds] - ); - - const openEvidenceDetail = (record) => { - const requestId = chatRequestRef.current + 1; - chatRequestRef.current = requestId; - setEvidenceRecord({ analysis, record }); - setDetailTab('all-chats'); - setExpandedCustomerIds([]); - const conversationIds = getAffectedConversationIds(record); - const goodsId = String(chatContext.goodsId || '').trim(); - const startDate = String(chatContext.startDate || '').trim(); - const endDate = String(chatContext.endDate || '').trim(); - if (!goodsId || !startDate || !endDate || conversationIds.length === 0) { - setRelatedChats({ - loading: false, - rows: [], - error: conversationIds.length === 0 - ? '当前质检记录没有保存涉及客户信息,暂无可关联的完整聊天记录。' - : '当前质检记录缺少商品或聊天周期,无法读取完整聊天记录。', - }); - return; - } - - setRelatedChats({ loading: true, rows: [], error: '' }); - getCsQualityMessages({ - goodsId, - startDate, - endDate, - shopName: chatContext.shopName || '', - }).then((response) => { - if (chatRequestRef.current !== requestId) return; - const payload = response?.data?.data || response?.data || response; - const nextRows = Array.isArray(payload?.rows) ? payload.rows : []; - const allowedConversationIds = new Set(conversationIds); - const firstAffectedRow = nextRows.find((row) => allowedConversationIds.has(String(row?.uid || '').trim())); - setRelatedChats({ - loading: false, - rows: nextRows, - error: '', - }); - setExpandedCustomerIds(firstAffectedRow ? [String(firstAffectedRow.uid).trim()] : []); - }).catch((error) => { - if (chatRequestRef.current !== requestId) return; - setRelatedChats({ - loading: false, - rows: [], - error: error.response?.data?.error || error.message || '完整聊天记录加载失败', - }); - }); - }; - - const closeEvidenceDetail = () => { - chatRequestRef.current += 1; - setEvidenceRecord(null); - setRelatedChats({ loading: false, rows: [], error: '' }); - setExpandedCustomerIds([]); - }; - - const openCategoryDrawer = (title, filterType = '', aggregate = false) => { - const filteredRows = filterType ? rows.filter((row) => row.consultation_type === filterType) : rows; - setCategoryDrawer({ - title, - filterType, - aggregate, - analysis, - rows: aggregate ? aggregateIssueTags(filteredRows) : withOccurrenceShare(filteredRows), - }); - }; - - const columns = [ - { - title: '类型', - dataIndex: 'consultation_type', - width: 72, - render: (value) => {value || '--'}, - }, - { title: '一级原因', dataIndex: 'primary_reason', width: 130, ellipsis: true }, - { title: '二级原因', dataIndex: 'secondary_reason', width: 150, ellipsis: true }, - { - title: '问题次数', - dataIndex: 'occurrence_count', - width: 88, - align: 'right', - render: formatNumber, - }, - { - title: '涉及客户', - dataIndex: 'affected_customer_count', - width: 88, - align: 'right', - render: formatNumber, - }, - { - title: '置信度', - dataIndex: 'confidence', - width: 86, - align: 'right', - render: formatConfidence, - }, - { - title: '操作', - key: 'action', - width: 98, - fixed: 'right', - render: (_value, record) => ( - - ), - }, - ]; - - const categoryColumns = [ - { - title: '类型', - dataIndex: 'consultation_type', - width: 72, - render: (value) => {value || '--'}, - }, - { - title: '问题标签', - key: 'reason', - render: (_value, record) =>
{record.primary_reason || '--'}{record.secondary_reason || '--'}
, - }, - { title: '问题次数', dataIndex: 'occurrence_count', width: 96, align: 'right', render: formatNumber }, - { title: '问题占比', dataIndex: 'occurrence_share', width: 96, align: 'right', render: (value) => formatConfidence(value) }, - { title: '涉及客户', dataIndex: 'affected_customer_count', width: 96, align: 'right', render: formatNumber }, - { title: '置信度', dataIndex: 'confidence', width: 86, align: 'right', render: formatConfidence }, - { - title: '操作', - key: 'action', - width: 98, - fixed: 'right', - render: (_value, record) => , - }, - ]; - - if (!analysis) { - return ; - } - - const renderAllChats = (record) => { - const messageCount = groupedRelatedChats.reduce((sum, group) => sum + group.rows.length, 0); - const expectedCustomerCount = Number(record.affected_customer_count) || 0; - const hasIncompleteCustomerIds = expectedCustomerCount > relatedConversationIds.length; - - return ( - -
- - 已关联 {formatNumber(groupedRelatedChats.length)} 个客户、{formatNumber(messageCount)} 条完整聊天记录 - -
- {hasIncompleteCustomerIds && !relatedChats.loading && ( - - )} - {relatedChats.error && } - {!relatedChats.loading && !relatedChats.error && groupedRelatedChats.length === 0 && ( - - )} -
- {groupedRelatedChats.map((group) => ( -
{ - const uid = group.uid; - setExpandedCustomerIds((current) => event.currentTarget.open - ? Array.from(new Set([...current, uid])) - : current.filter((value) => value !== uid)); - }} - > - -
- {group.nickname || group.uid} - UID:{group.uid} -
- {formatNumber(group.rows.length)} 条消息 -
-
-
- {group.rows.map((item, index) => { - const isService = item.directionLabel === '客服'; - return ( -
-
- {item.directionLabel || '未标记'} - {item.csname ? 客服:{item.csname} : null} - {item.msgTime || '--'} -
- {item.content || '--'} -
- ); - })} -
-
-
- ))} -
-
- ); - }; - - const renderKeyEvidence = (record) => ( - <> - {Array.isArray(record.evidence) && record.evidence.length > 0 ? ( -
- {record.evidence.map((item, index) => ( -
- {item.nickname || item.conversation_id || '买家'}{item.message_time ? ` · ${item.message_time}` : ''} - {item.text || '--'} -
- ))} -
- ) : } - {Array.isArray(record.image_urls) && record.image_urls.some((url) => IMAGE_URL_PATTERN.test(String(url))) && ( -
- {record.image_urls.filter((url) => IMAGE_URL_PATTERN.test(String(url))).map((url) => )} -
- )} - - ); - - const renderRecommendation = (record) => ( -
- {Array.isArray(record.product_improvement_areas) && record.product_improvement_areas.length > 0 && ( -
- 影响环节: - {record.product_improvement_areas.map((item) => {item})} -
- )} - {record.improvement_suggestion &&
改进建议:{record.improvement_suggestion}
} -
- ); - - const renderDetailPane = (content, record) => ( -
- {content} - {renderRecommendation(record)} -
- ); - - const renderEvidence = (record) => ( -
- - {record.consultation_type || '--'} - {formatConfidence(record.confidence)} - {formatNumber(record.occurrence_count)} - {formatNumber(record.affected_customer_count)} - - {record.buyer_intent &&
买家诉求:{record.buyer_intent}
} - {Array.isArray(record.sourceRows) && record.sourceRows.length > 0 && ( -
- 细分问题: - {record.sourceRows.map((item, index) => {item.secondary_reason || '--'} · {formatNumber(item.occurrence_count)} 次)} -
- )} - -
- ); - - return ( -
-
-
- 综合判断 - {analysis.conversation_type || '--'} -
-
会话数{formatNumber(summary.conversation_count)}
- - - -
- - {Array.isArray(summary.main_findings) && summary.main_findings.length > 0 && ( -
- 主要发现 -
    {summary.main_findings.map((item, index) =>
  • {item}
  • )}
-
- )} - -
`quality-result-${index}`} - columns={columns} - dataSource={rows} - pagination={false} - scroll={{ x: 820 }} - locale={{ emptyText: }} - /> - - setCategoryDrawer(null)} - footer={null} - destroyOnHidden - styles={{ - body: { - maxHeight: 'calc(100vh - 220px)', - overflowY: 'auto', - paddingRight: 4, - }, - }} - > -
- {activeCategoryDrawer?.filterType ? `${activeCategoryDrawer.filterType}问题分类` : '问题分类汇总'} - - {activeCategoryDrawer?.aggregate - ? `共 ${formatNumber(activeCategoryDrawer?.rows?.length || 0)} 类问题,包含 ${formatNumber(rows.length)} 个细分标签` - : `共 ${formatNumber(activeCategoryDrawer?.rows?.length || 0)} 个问题标签`} - -
-
`quality-category-${index}`} - columns={categoryColumns} - dataSource={activeCategoryDrawer?.rows || []} - pagination={false} - scroll={{ x: 760 }} - locale={{ emptyText: }} - /> - - - - {activeEvidenceRecord ? renderEvidence(activeEvidenceRecord) : null} - - - ); -}; - -export default AiQualityResult; diff --git a/frontend/src/pages/CustomerServiceQuality/index.jsx b/frontend/src/pages/CustomerServiceQuality/index.jsx deleted file mode 100644 index bf01e77..0000000 --- a/frontend/src/pages/CustomerServiceQuality/index.jsx +++ /dev/null @@ -1,634 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - Button, - DatePicker, - Empty, - Image, - Input, - Pagination, - Space, - Spin, - Table, - Tag, - Tooltip, - Typography, - message, -} from 'antd'; -import { - DownloadOutlined, - HistoryOutlined, - MessageOutlined, - SafetyCertificateOutlined, - SearchOutlined, - UserOutlined, -} from '@ant-design/icons'; -import dayjs from 'dayjs'; -import { SycmBusinessPanel, SycmPage } from '../../components/Common'; -import { - getCsQualityDataMeta, - getCsQualityGoods, - getCsQualityCustomers, - getCsQualityMessages, -} from '../../api/csQuality'; -import AiQualityDrawer from './AiQualityDrawer'; -import AiQualityHistoryDrawer from './AiQualityHistoryDrawer'; -import './style.css'; - -const { RangePicker } = DatePicker; -const { Text, Paragraph } = Typography; - -const DEFAULT_DATE = dayjs().subtract(1, 'day'); -const DEFAULT_RANGE = [DEFAULT_DATE, DEFAULT_DATE]; -const PAGE_SIZE = 20; -const CHAT_IMAGE_URL_RE = /^https?:\/\/\S+$/i; -const CHAT_IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i; -const CHAT_VIDEO_EXT_RE = /\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i; - -function formatNumber(value) { - const num = Number(value); - if (!Number.isFinite(num)) return '0'; - return num.toLocaleString('zh-CN'); -} - -function formatText(value) { - if (value === null || value === undefined || value === '') return '--'; - if (typeof value === 'object') return JSON.stringify(value); - return String(value); -} - -function buildSingleDayRange(value) { - const date = dayjs(value); - return date.isValid() ? [date, date] : DEFAULT_RANGE; -} - -function isServiceDirection(value) { - return String(value || '').includes('客服'); -} - -function getDirectionTone(value) { - if (isServiceDirection(value)) return 'outbound'; - return 'inbound'; -} - -function getChatImageUrl(value) { - const text = String(value || '').trim(); - if (!text || !CHAT_IMAGE_URL_RE.test(text)) return ''; - try { - const url = new window.URL(text); - if (CHAT_VIDEO_EXT_RE.test(url.pathname || '')) return ''; - if (CHAT_IMAGE_EXT_RE.test(url.pathname || '')) { - return text; - } - } catch { /* 忽略 */ } - return ''; -} - -function sortChatRows(rows = []) { - return [...rows].sort((left, right) => { - const leftTime = dayjs(left?.msgTime).valueOf(); - const rightTime = dayjs(right?.msgTime).valueOf(); - if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { - return leftTime - rightTime; - } - return String(left?.msgTime || '').localeCompare(String(right?.msgTime || ''), 'zh-CN'); - }); -} - -function downloadTextFile(filename, content) { - const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' }); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); -} - -const CustomerServiceQuality = () => { - const [goodsLoading, setGoodsLoading] = useState(false); - const [customersLoading, setCustomersLoading] = useState(false); - const [messagesLoading, setMessagesLoading] = useState(false); - const [exportingGoodsId, setExportingGoodsId] = useState(''); - const [goodsRows, setGoodsRows] = useState([]); - const [customerRows, setCustomerRows] = useState([]); - const [messageRows, setMessageRows] = useState([]); - const [selectedGoods, setSelectedGoods] = useState(null); - const [selectedCustomer, setSelectedCustomer] = useState(null); - const [tableName, setTableName] = useState(''); - const [dataMeta, setDataMeta] = useState({ minDate: '', latestDate: '', messageCount: 0 }); - const [errorText, setErrorText] = useState(''); - const [goodsPage, setGoodsPage] = useState(1); - const [customersPage, setCustomersPage] = useState(1); - const [messagesPage, setMessagesPage] = useState(1); - - const [dateRange, setDateRange] = useState(DEFAULT_RANGE); - const [shopName, setShopName] = useState(''); - const [goodsId, setGoodsId] = useState(''); - const [aiGoods, setAiGoods] = useState(null); - const [historyOpen, setHistoryOpen] = useState(false); - const [historyRefreshToken, setHistoryRefreshToken] = useState(0); - const goodsRequestRef = useRef(0); - const customersRequestRef = useRef(0); - const messagesRequestRef = useRef(0); - - const buildBaseParams = useCallback((filters) => { - const nextFilters = filters || { dateRange, shopName, goodsId }; - const params = {}; - if (nextFilters.dateRange?.length === 2) { - params.startDate = nextFilters.dateRange[0].format('YYYY-MM-DD'); - params.endDate = nextFilters.dateRange[1].format('YYYY-MM-DD'); - } - if (nextFilters.shopName) params.shopName = String(nextFilters.shopName).trim(); - if (nextFilters.goodsId) params.goodsId = String(nextFilters.goodsId).trim(); - return params; - }, [dateRange, shopName, goodsId]); - - const loadMessages = useCallback(async (customerRecord, goodsRecord = selectedGoods, filters) => { - if (!goodsRecord?.goodsId || !customerRecord?.uid) return; - const requestId = messagesRequestRef.current + 1; - messagesRequestRef.current = requestId; - setSelectedCustomer(customerRecord); - setMessageRows([]); - setMessagesPage(1); - setMessagesLoading(true); - try { - const response = await getCsQualityMessages({ - ...buildBaseParams(filters), - goodsId: goodsRecord.goodsId, - uid: customerRecord.uid, - }); - if (requestId !== messagesRequestRef.current) return; - const data = response?.data || response; - const payload = data?.data || data; - setMessageRows(Array.isArray(payload.rows) ? payload.rows : []); - setSelectedCustomer(customerRecord); - setMessagesPage(1); - setErrorText(''); - } catch (error) { - if (requestId !== messagesRequestRef.current) return; - const msg = error.response?.data?.error || error.message || '加载聊天内容失败'; - message.error(msg); - setErrorText(msg); - setMessageRows([]); - } finally { - if (requestId === messagesRequestRef.current) setMessagesLoading(false); - } - }, [selectedGoods, buildBaseParams]); - - const loadCustomers = useCallback(async (goodsRecord, filters) => { - if (!goodsRecord?.goodsId) return; - const requestId = customersRequestRef.current + 1; - customersRequestRef.current = requestId; - messagesRequestRef.current += 1; - setCustomersLoading(true); - setMessagesLoading(false); - setSelectedGoods(goodsRecord); - setCustomersPage(1); - setMessagesPage(1); - try { - const baseParams = buildBaseParams(filters); - const response = await getCsQualityCustomers({ - ...baseParams, - goodsId: goodsRecord.goodsId, - snapshotScopeGoodsId: baseParams.goodsId || undefined, - }); - if (requestId !== customersRequestRef.current) return; - const data = response?.data || response; - const payload = data?.data || data; - const rows = Array.isArray(payload.rows) ? payload.rows : []; - setCustomerRows(rows); - setErrorText(''); - if (rows[0]) { - await loadMessages(rows[0], goodsRecord, filters); - } else { - setMessageRows([]); - setSelectedCustomer(null); - } - } catch (error) { - if (requestId !== customersRequestRef.current) return; - const msg = error.response?.data?.error || error.message || '加载客户列表失败'; - message.error(msg); - setErrorText(msg); - setCustomerRows([]); - setMessageRows([]); - setSelectedCustomer(null); - } finally { - if (requestId === customersRequestRef.current) setCustomersLoading(false); - } - }, [buildBaseParams, loadMessages]); - - const loadGoods = useCallback(async (filters) => { - const requestId = goodsRequestRef.current + 1; - goodsRequestRef.current = requestId; - customersRequestRef.current += 1; - messagesRequestRef.current += 1; - setGoodsLoading(true); - setCustomersLoading(false); - setMessagesLoading(false); - try { - const response = await getCsQualityGoods({ - ...buildBaseParams(filters), - includeInitial: true, - }); - if (requestId !== goodsRequestRef.current) return; - const data = response?.data || response; - if (!data?.success && data?.success !== undefined) throw new Error(data?.error || '加载商品聊天列表失败'); - const payload = data?.data || data; - const rows = Array.isArray(payload.rows) ? payload.rows : []; - setTableName(payload.tableName || ''); - setGoodsRows(rows); - setGoodsPage(1); - setCustomersPage(1); - setMessagesPage(1); - setErrorText(''); - if (rows[0]) { - const initial = payload.initial; - const hasInitial = String(initial?.goodsId || '') === String(rows[0].goodsId || '') - && Array.isArray(initial?.customers); - if (hasInitial) { - const initialCustomers = initial.customers; - const initialCustomer = initialCustomers[0] || null; - setSelectedGoods(rows[0]); - setCustomerRows(initialCustomers); - setSelectedCustomer(initialCustomer); - if (initialCustomer && initial.messagesPrefetched - && String(initial.customerUid || '') === String(initialCustomer.uid || '')) { - setMessageRows(Array.isArray(initial.messages) ? initial.messages : []); - } else if (initialCustomer) { - await loadMessages(initialCustomer, rows[0], filters); - } else { - setMessageRows([]); - } - } else { - await loadCustomers(rows[0], filters); - } - } else { - setCustomerRows([]); - setMessageRows([]); - setSelectedGoods(null); - setSelectedCustomer(null); - } - } catch (error) { - if (requestId !== goodsRequestRef.current) return; - const msg = error.response?.data?.error || error.message || '加载商品聊天列表失败'; - message.error(msg); - setErrorText(msg); - setGoodsRows([]); - setCustomerRows([]); - setMessageRows([]); - setSelectedGoods(null); - setSelectedCustomer(null); - } finally { - if (requestId === goodsRequestRef.current) setGoodsLoading(false); - } - }, [buildBaseParams, loadCustomers, loadMessages]); - - useEffect(() => { - let active = true; - const initialize = async () => { - let initialRange = DEFAULT_RANGE; - try { - const response = await getCsQualityDataMeta(); - if (!active) return; - const data = response?.data || response; - const payload = data?.data || data; - setDataMeta({ - minDate: payload?.minDate || '', - latestDate: payload?.latestDate || '', - messageCount: Number(payload?.messageCount || 0), - }); - if (payload?.latestDate) { - initialRange = buildSingleDayRange(payload.latestDate); - setDateRange(initialRange); - } - } catch (error) { - console.warn('[CustomerServiceQuality] 加载聊天数据日期范围失败:', error.message || error); - } - if (active) loadGoods({ dateRange: initialRange }); - }; - initialize(); - return () => { active = false; }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - const handleQuery = () => loadGoods(); - - const handleReset = () => { - const next = { - dateRange: dataMeta.latestDate ? buildSingleDayRange(dataMeta.latestDate) : DEFAULT_RANGE, - shopName: '', - goodsId: '', - }; - setDateRange(next.dateRange); - setShopName(next.shopName); - setGoodsId(next.goodsId); - loadGoods(next); - }; - - const handleOpenAiQuality = (goodsRecord, event) => { - event?.stopPropagation?.(); - setAiGoods(goodsRecord); - }; - - const handleDownloadGoodsChat = async (goodsRecord, event) => { - event?.stopPropagation?.(); - if (!goodsRecord?.goodsId) return; - setExportingGoodsId(String(goodsRecord.goodsId)); - try { - const response = await getCsQualityMessages({ - ...buildBaseParams(), - goodsId: goodsRecord.goodsId, - }); - const data = response?.data || response; - const payload = data?.data || data; - const rows = Array.isArray(payload.rows) ? payload.rows : []; - if (!rows.length) { - message.warning('当前筛选时间段内没有可下载的聊天记录'); - return; - } - const sorted = sortChatRows(rows); - const customerMap = new Map(); - sorted.forEach((row) => { - const key = String(row?.uid || 'unknown'); - if (!customerMap.has(key)) customerMap.set(key, { uid: row?.uid, nickname: row?.nickname, rows: [] }); - customerMap.get(key).rows.push(row); - }); - const header = [ - '商品聊天记录导出', - `商品ID:${goodsRecord.goodsId || '--'}`, - `商品名称:${goodsRecord.goodsName || '--'}`, - `店铺名称:${goodsRecord.shopName || '--'}`, - `时间范围:${dateRange?.[0]?.format('YYYY-MM-DD') || '--'} ~ ${dateRange?.[1]?.format('YYYY-MM-DD') || '--'}`, - `导出时间:${dayjs().format('YYYY-MM-DD HH:mm:ss')}`, - `消息总数:${sorted.length}`, - `客户总数:${customerMap.size}`, - ''.padEnd(64, '='), - ]; - const blocks = Array.from(customerMap.values()).map((c) => { - const blockHeader = [`客户:${c.nickname || '--'} (UID: ${c.uid || '--'})`, `消息数:${c.rows.length}`, ''.padEnd(64, '-')]; - const lines = c.rows.map((r) => `[${r?.msgTime || '--'} | ${r?.directionLabel || ''}${r?.csname ? ` | 客服:${r.csname}` : ''}]\r\n${r?.content || '--'}`); - return [...blockHeader, ...lines].join('\r\n'); - }); - downloadTextFile(`聊天记录_${goodsRecord.goodsId}_${dateRange?.[0]?.format('YYYYMMDD') || 'start'}_${dateRange?.[1]?.format('YYYYMMDD') || 'end'}.txt`, [...header, ...blocks].join('\r\n\r\n')); - message.success(`已下载 ${sorted.length} 条聊天记录`); - } catch (error) { - message.error(error.message || '下载失败'); - } finally { - setExportingGoodsId(''); - } - }; - - const goodsColumns = useMemo(() => [ - { - title: '商品', - dataIndex: 'goodsName', - key: 'goodsName', - render: (_value, record) => ( -
- {record.hdThumbUrl ? ( - {record.goodsName - ) : ( - 暂无 - )} -
- {record.goodsName || record.goodsId || '--'} - ID:{record.goodsId || '--'} - {record.shopName ? {record.shopName} : null} -
-
- ), - }, - { - title: '消息', - dataIndex: 'messageCount', - key: 'messageCount', - width: 150, - render: (value, record) => ( -
- {formatNumber(value)} - {formatNumber(record.userCount)} 客户 - -
- ), - }, - { - title: '', - key: 'aiQuality', - width: 48, - align: 'center', - render: (_value, record) => ( - - - - -
- - ( - (dataMeta.minDate && current?.isBefore(dayjs(dataMeta.minDate), 'day')) - || (dataMeta.latestDate && current?.isAfter(dayjs(dataMeta.latestDate), 'day')) - )} - /> - setShopName(e.target.value)} onPressEnter={handleQuery} allowClear style={{ width: 180 }} /> - setGoodsId(e.target.value)} onPressEnter={handleQuery} allowClear style={{ width: 160 }} /> - - - -
- - {errorText &&
{errorText}
} -
- 数据来源:{tableName || 'PDD 商品 T-1 聊天记录数据表'}; - 数据范围:{dataMeta.minDate && dataMeta.latestDate ? `${dataMeta.minDate} 至 ${dataMeta.latestDate}` : '--'}; - 累计消息 {formatNumber(dataMeta.messageCount)} 条; - 当前商品 {goodsRows.length} 个 -
- -
-
-
- 第一级:商品ID - {formatNumber(goodsRows.length)} 个商品 -
-
-
selectedGoods?.goodsId === record.goodsId ? 'csq-drill-row csq-drill-row--active' : 'csq-drill-row'} - onRow={(record) => ({ onClick: () => { setCustomersPage(1); loadCustomers(record); } })} - locale={{ emptyText: }} - /> - - {goodsRows.length > PAGE_SIZE && ( -
- -
- )} - - -
-
- {selectedGoodsTitle} - {formatNumber(customerRows.length)} 个客户 -
- {selectedGoods ? ( -
- -
selectedCustomer?.uid === record.uid ? 'csq-drill-row csq-drill-row--active' : 'csq-drill-row'} - onRow={(record) => ({ onClick: () => loadMessages(record, selectedGoods) })} - locale={{ emptyText: }} - /> - - - ) : ( -
- )} - {customerRows.length > PAGE_SIZE && ( -
- -
- )} - - -
-
- {selectedCustomerTitle} - {formatNumber(messageRows.length)} 条消息 -
- {selectedCustomer ? ( -
- -
- {pagedMessageRows.length ? pagedMessageRows.map((item, index) => { - const imageUrl = getChatImageUrl(item.content); - return ( -
-
- {item.directionLabel || '未标记'} - {item.csname ? 客服:{item.csname} : null} - {item.nickname ? 买家:{item.nickname} : null} - {formatText(item.msgTime)} -
-
- {imageUrl ? ( - - ) : ( - <> - - - {formatText(item.content)} - - - )} -
-
- ); - }) : ( - - )} -
-
-
- ) : ( -
- )} - {messageRows.length > PAGE_SIZE && ( -
- -
- )} -
- - - - setAiGoods(null)} - onCompleted={() => setHistoryRefreshToken((value) => value + 1)} - /> - setHistoryOpen(false)} - /> - - ); -}; - -export default CustomerServiceQuality; diff --git a/frontend/src/pages/CustomerServiceQuality/style.css b/frontend/src/pages/CustomerServiceQuality/style.css deleted file mode 100644 index c6e7a06..0000000 --- a/frontend/src/pages/CustomerServiceQuality/style.css +++ /dev/null @@ -1,872 +0,0 @@ -.csq-page { - display: flex; - flex-direction: column; - height: 100vh; - height: 100dvh; - min-height: 0; - max-height: 100vh; - max-height: 100dvh; - box-sizing: border-box; - overflow: hidden; -} - -.csq-panel { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.csq-panel-inner { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.csq-panel__heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-sm); - padding: var(--sycm-space-sm) var(--sycm-module-padding) 0; - flex: 0 0 auto; -} - -.csq-panel__heading-main { - display: flex; - min-width: 0; - align-items: baseline; - gap: var(--sycm-space-sm); -} - -.csq-panel__heading h2 { - margin: 0; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); - line-height: var(--sycm-line-height-tight); -} - -.csq-panel__heading span { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); - line-height: var(--sycm-line-height-normal); -} - -.csq-filter { - display: flex; - padding: var(--sycm-space-xs) var(--sycm-module-padding); - background: var(--sycm-bg-subtle); - flex: 0 0 auto; -} - -.csq-error { - margin: 0 var(--sycm-module-padding); - padding: var(--sycm-space-xs) var(--sycm-space-sm); - border: 1px solid var(--sycm-color-danger); - border-radius: var(--sycm-radius-md); - background: var(--sycm-color-danger-bg); - color: var(--sycm-color-danger); - font-size: var(--sycm-font-size-caption); - flex: 0 0 auto; -} - -.csq-info { - padding: var(--sycm-space-xxs) var(--sycm-module-padding); - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); - flex: 0 0 auto; -} - -.csq-drill-grid { - display: grid; - grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr) minmax(0, 1.35fr); - grid-template-rows: minmax(0, 1fr); - height: calc(100dvh - 146px); - max-height: calc(100dvh - 146px); - gap: var(--sycm-space-sm); - padding: 0 var(--sycm-module-padding) var(--sycm-space-sm); - flex: 0 0 calc(100dvh - 146px); - min-height: 0; - overflow: hidden; -} - -.csq-drill-card { - display: flex; - flex-direction: column; - height: 100%; - max-height: 100%; - min-height: 0; - overflow: hidden; - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-radius: var(--sycm-radius-lg); - background: var(--sycm-bg-container); -} - -.csq-drill-card__header { - display: flex; - align-items: center; - justify-content: space-between; - min-height: 40px; - padding: 0 var(--sycm-space-sm); - border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); - background: var(--sycm-bg-subtle); - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-semibold); - flex: 0 0 auto; -} - -.csq-drill-card__extra { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); - font-weight: var(--sycm-font-weight-regular); -} - -/* 中间可滚动区域 */ -.csq-scroll-area { - flex: 1; - min-height: 0; - overscroll-behavior: contain; - overflow-y: scroll; - scrollbar-gutter: stable; -} - -.csq-table-spin, -.csq-table-spin > .ant-spin-container { - display: flex; - min-height: 0; - flex: 1; - flex-direction: column; -} - -.csq-table-spin.ant-spin-nested-loading { - min-height: 0; -} - -/* 分页固定在底部 */ -.csq-drill-card__footer { - display: flex; - min-height: 36px; - align-items: center; - justify-content: flex-end; - flex: 0 0 auto; - border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); - padding: 4px var(--sycm-space-sm); - background: var(--sycm-bg-container); -} - -.csq-drill-row { - cursor: pointer; - transition: background-color 0.15s ease, box-shadow 0.15s ease; -} - -.csq-drill-row:hover > td { - background: rgba(32, 98, 230, 0.04) !important; -} - -.csq-drill-row--active > td { - background: var(--sycm-color-primary-bg) !important; - box-shadow: inset 3px 0 0 var(--sycm-color-primary); -} - -.csq-goods-cell, -.csq-customer-cell { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: var(--sycm-space-xs); - align-items: center; -} - -.csq-goods-thumb { - display: block; - width: 40px; - height: 40px; - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-radius: var(--sycm-radius-sm); - object-fit: cover; -} - -.csq-goods-thumb.is-empty { - display: inline-flex; - align-items: center; - justify-content: center; - background: var(--sycm-bg-subtle); - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); -} - -.csq-cell-main { - display: flex; - min-width: 0; - flex-direction: column; - gap: 1px; - line-height: var(--sycm-line-height-normal); -} - -.csq-cell-primary { - overflow: hidden; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); - text-overflow: ellipsis; - white-space: nowrap; -} - -.csq-cell-secondary { - overflow: hidden; - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); - text-overflow: ellipsis; - white-space: nowrap; -} - -.csq-count-cell { - display: flex; - min-width: 0; - flex-direction: column; - align-items: flex-end; - gap: 1px; -} - -.csq-download-btn { - padding-inline: 0; - font-size: var(--sycm-font-size-micro); -} - -.csq-ai-action { - color: var(--sycm-color-primary); -} - -.csq-ai-action:hover { - background: var(--sycm-color-primary-bg) !important; - color: var(--sycm-color-primary) !important; -} - -.csq-customer-avatar { - display: inline-flex; - width: 34px; - height: 34px; - align-items: center; - justify-content: center; - border-radius: 12px; - background: var(--sycm-color-primary-bg); - color: var(--sycm-color-primary); - font-size: 16px; -} - -.csq-message-card .csq-drill-card__header { - flex: 0 0 auto; -} - -.csq-message-list { - display: flex; - flex-direction: column; - gap: var(--sycm-space-sm); - padding: var(--sycm-space-sm); -} - -.csq-message-scroll > .ant-spin-nested-loading, -.csq-message-scroll > .ant-spin-nested-loading > .ant-spin-container { - min-height: 100%; -} - -.csq-message { - display: flex; - flex-direction: column; - gap: 4px; -} - -.csq-message--outbound { - align-items: flex-end; -} - -.csq-message--inbound { - align-items: flex-start; -} - -.csq-message__meta { - display: flex; - max-width: 86%; - flex-wrap: wrap; - gap: 6px; - align-items: center; -} - -.csq-message__bubble { - display: flex; - max-width: 86%; - gap: 6px; - align-items: flex-start; - padding: var(--sycm-space-xs) var(--sycm-space-sm); - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-radius: 12px; - background: var(--sycm-bg-container); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); - font-size: var(--sycm-font-size-caption); - line-height: var(--sycm-line-height-normal); -} - -.csq-message__bubble--image { - display: block; - padding: 4px; -} - -.csq-message__image { - display: block; - width: 100px; - max-width: 100px; -} - -.csq-message__image .ant-image-img { - display: block; - width: 100px; - height: 100px; - border-radius: var(--sycm-radius-md); - object-fit: contain; - background: var(--sycm-bg-subtle); -} - -.csq-message--outbound .csq-message__bubble { - border-color: rgba(32, 98, 230, 0.18); - background: var(--sycm-color-primary-soft); -} - -.csq-message__bubble .anticon { - margin-top: 2px; - color: var(--sycm-color-primary); -} - -.csq-ai-section { - padding: 0 0 var(--sycm-space-lg); - margin-bottom: var(--sycm-space-lg); - border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); -} - -.csq-ai-section:last-child { - margin-bottom: 0; - border-bottom: 0; -} - -.csq-ai-product { - display: flex; - gap: var(--sycm-space-md); - align-items: center; -} - -.csq-ai-product__image, -.csq-history-detail-product .ant-image-img { - flex: 0 0 auto; - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-radius: var(--sycm-radius-md); - background: var(--sycm-bg-subtle); - object-fit: contain; -} - -.csq-ai-product__content { - min-width: 0; -} - -.csq-ai-product__content h5 { - margin: 0 0 var(--sycm-space-xs); - color: var(--sycm-text-strong); -} - -.csq-ai-product__meta { - display: flex; - flex-wrap: wrap; - gap: 6px 18px; - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); -} - -.csq-ai-section__title { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-md); - margin-bottom: var(--sycm-space-sm); -} - -.csq-ai-section__title > div { - display: flex; - align-items: baseline; - gap: var(--sycm-space-sm); -} - -.csq-ai-section__title h5 { - margin: 0; -} - -.csq-ai-metrics { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-left: var(--sycm-line-width-base) solid var(--sycm-border-light); -} - -.csq-ai-metrics > div { - display: flex; - min-height: 64px; - flex-direction: column; - justify-content: center; - gap: 3px; - padding: var(--sycm-space-xs) var(--sycm-space-sm); - border-right: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); -} - -.csq-ai-metrics span { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); -} - -.csq-ai-metrics strong { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); -} - -.csq-ai-period-note { - display: flex; - align-items: center; - gap: var(--sycm-space-sm); - margin-top: var(--sycm-space-sm); -} - -.csq-ai-progress { - min-height: 132px; - padding: var(--sycm-space-md) 18px; - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-left: 3px solid var(--sycm-color-primary); - background: var(--sycm-bg-subtle); -} - -.csq-ai-progress__heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--sycm-space-md); - margin-bottom: var(--sycm-space-sm); -} - -.csq-ai-progress__heading > div { - display: flex; - min-width: 0; - flex-direction: column; - gap: 4px; -} - -.csq-ai-progress__heading strong { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-body); - font-weight: var(--sycm-font-weight-semibold); - line-height: var(--sycm-line-height-normal); -} - -.csq-ai-progress__heading > span { - min-width: 42px; - color: var(--sycm-color-primary); - font-size: var(--sycm-font-size-title); - font-variant-numeric: tabular-nums; - font-weight: var(--sycm-font-weight-semibold); - text-align: right; -} - -.csq-ai-progress .ant-progress { - margin: 0; -} - -.csq-ai-progress__meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-md); - min-height: 22px; - margin-top: var(--sycm-space-xs); -} - -.csq-ai-progress__meta > span:last-child { - min-width: 0; - text-align: right; -} - -.csq-ai-progress-error { - margin-bottom: var(--sycm-space-md); -} - -.csq-ai-result__summary { - display: grid; - grid-template-columns: 1.35fr repeat(4, 1fr); - gap: var(--sycm-space-sm); - margin-bottom: var(--sycm-space-md); - padding: var(--sycm-space-sm); - background: var(--sycm-bg-subtle); -} - -.csq-ai-result__summary > div { - display: flex; - min-width: 0; - flex-direction: column; - gap: 4px; -} - -.csq-ai-result__summary > div:first-child { - flex-direction: row; - align-items: center; - gap: var(--sycm-space-xs); -} - -.csq-ai-result__summary strong { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); -} - -.csq-ai-findings { - margin-bottom: var(--sycm-space-md); - padding: var(--sycm-space-sm) var(--sycm-space-md); - border-left: 3px solid var(--sycm-color-primary); - background: var(--sycm-color-primary-bg); -} - -.csq-ai-findings ul { - margin: var(--sycm-space-xs) 0 0; - padding-left: 20px; - color: var(--sycm-text-secondary); -} - -.csq-ai-result__detail { - display: flex; - height: 100%; - min-height: 0; - flex-direction: column; - gap: var(--sycm-space-sm); - padding: 0; - color: var(--sycm-text-secondary); - line-height: var(--sycm-line-height-normal); -} - -.csq-ai-evidence-list { - display: flex; - flex-direction: column; - gap: 4px; - margin-top: 4px; -} - -.csq-ai-evidence-list blockquote { - margin: 0; - padding: 6px 10px; - border-left: 2px solid var(--sycm-border-light); - background: var(--sycm-bg-subtle); -} - -.csq-ai-evidence-list blockquote > span { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); -} - -.csq-ai-evidence-list p { - margin: 2px 0 0; -} - -.csq-ai-result__detail > .ant-descriptions, -.csq-ai-result__detail > .csq-ai-result__tags, -.csq-ai-result__detail > div:not(.ant-tabs) { - flex: 0 0 auto; -} - -.csq-ai-result__detail > .ant-tabs { - display: flex; - min-height: 0; - flex: 1; - flex-direction: column; -} - -.csq-ai-result__detail > .ant-tabs > .ant-tabs-nav { - flex: 0 0 auto; - margin-bottom: 0 !important; -} - -.csq-ai-result__detail > .ant-tabs > .ant-tabs-content-holder { - min-height: 0; - flex: 1; - padding-top: var(--sycm-space-sm); - padding-right: 6px; - overflow-y: auto; - scrollbar-gutter: stable; -} - -.csq-ai-detail-pane { - display: flex; - min-height: 100%; - flex-direction: column; - gap: var(--sycm-space-md); -} - -.csq-ai-chat-summary { - display: flex; - min-height: 30px; - align-items: center; - justify-content: space-between; - margin-bottom: var(--sycm-space-xs); -} - -.csq-ai-chat-collapse { - border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); - background: transparent; -} - -.csq-ai-chat-customer { - border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); -} - -.csq-ai-chat-customer > summary { - position: relative; - padding: 10px 0; - align-items: center; - cursor: pointer; - list-style: none; -} - -.csq-ai-chat-customer > summary::-webkit-details-marker { - display: none; -} - -.csq-ai-chat-customer > summary::after { - position: absolute; - top: 50%; - right: 0; - width: 6px; - height: 6px; - border-right: 1px solid var(--sycm-text-tertiary); - border-bottom: 1px solid var(--sycm-text-tertiary); - content: ''; - transform: translateY(-65%) rotate(-45deg); -} - -.csq-ai-chat-customer[open] > summary::after { - transform: translateY(-75%) rotate(45deg); -} - -.csq-ai-chat-customer-body { - padding: 0 0 var(--sycm-space-md); -} - -.csq-ai-chat-customer-heading { - display: flex; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-sm); - padding-right: var(--sycm-space-sm); -} - -.csq-ai-chat-customer-heading > div { - display: flex; - min-width: 0; - flex-direction: column; - gap: 2px; -} - -.csq-ai-chat-customer-heading strong { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-body); -} - -.csq-ai-chat-customer-heading .ant-tag { - flex: 0 0 auto; - margin-inline-end: 0; -} - -.csq-ai-chat-messages { - display: flex; - flex-direction: column; - gap: 6px; -} - -.csq-ai-chat-message { - padding: 8px 10px; - border-left: 2px solid var(--sycm-border-light); - background: var(--sycm-bg-subtle); -} - -.csq-ai-chat-message--service { - border-left-color: var(--sycm-color-success); - background: var(--sycm-color-success-bg); -} - -.csq-ai-chat-message--buyer { - border-left-color: var(--sycm-color-primary); - background: var(--sycm-color-primary-bg); -} - -.csq-ai-chat-message__meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - margin-bottom: 4px; - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); -} - -.csq-ai-chat-message__meta .ant-tag { - margin-inline-end: 0; -} - -.csq-ai-chat-message p { - margin: 0; - color: var(--sycm-text-secondary); - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -.csq-ai-result__recommendation { - display: flex; - flex-direction: column; - gap: var(--sycm-space-sm); - padding: var(--sycm-space-sm) var(--sycm-space-md); - border-left: 3px solid var(--sycm-color-primary); - background: var(--sycm-bg-subtle); -} - -.csq-ai-result__tags, -.csq-ai-result__images { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; -} - -.csq-ai-result__images .ant-image-img { - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-radius: var(--sycm-radius-sm); - object-fit: contain; -} - -.csq-history-filter { - margin-bottom: var(--sycm-space-md); - padding-bottom: var(--sycm-space-md); - border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); -} - -.csq-history-product { - display: flex; - align-items: center; - gap: var(--sycm-space-xs); -} - -.csq-history-product .ant-image-img { - flex: 0 0 auto; - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-radius: var(--sycm-radius-sm); - background: var(--sycm-bg-subtle); - object-fit: contain; -} - -.csq-history-product > div, -.csq-history-stack { - display: flex; - min-width: 0; - flex-direction: column; - gap: 2px; -} - -.csq-history-product strong { - overflow: hidden; - color: var(--sycm-text-strong); - text-overflow: ellipsis; - white-space: nowrap; -} - -.csq-history-product span, -.csq-history-stack span { - overflow: hidden; - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); - text-overflow: ellipsis; - white-space: nowrap; -} - -.csq-history-pagination { - display: flex; - justify-content: flex-end; - margin-top: var(--sycm-space-md); -} - -.csq-history-detail-product { - padding-bottom: var(--sycm-space-md); -} - -@media (max-width: 760px) { - .csq-panel__heading-main { - align-items: flex-start; - flex-direction: column; - gap: 2px; - } - - .csq-ai-section__title { - align-items: flex-start; - flex-direction: column; - } - - .csq-ai-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .csq-ai-result__summary { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .csq-ai-progress__meta { - align-items: flex-start; - flex-direction: column; - gap: 2px; - } - - .csq-ai-progress__meta > span:last-child { - text-align: left; - } - - .csq-ai-chat-customer-heading { - align-items: flex-start; - } -} - -.csq-history-status { - display: flex; - align-items: center; - gap: 4px; -} - -.csq-history-status .ant-tag { - margin-inline-end: 0; -} - -.csq-history-status span { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); - font-variant-numeric: tabular-nums; -} - -@media (max-width: 1180px) { - .csq-page { - height: auto; - overflow: auto; - } - - .csq-drill-grid { - grid-template-columns: 1fr; - height: auto; - max-height: none; - flex-basis: auto; - overflow: visible; - } - - .csq-drill-card { - min-height: 400px; - } -} diff --git a/frontend/src/pages/DesktopClientDownload/index.jsx b/frontend/src/pages/DesktopClientDownload/index.jsx deleted file mode 100644 index 79efa3a..0000000 --- a/frontend/src/pages/DesktopClientDownload/index.jsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - AppleOutlined, - DownloadOutlined, - ReloadOutlined, - WindowsOutlined, -} from '@ant-design/icons'; -import { Button, Empty, Skeleton } from 'antd'; -import { getLatestDesktopRelease } from '../../api/desktopClient'; -import { SycmBusinessPanel, SycmPage } from '../../components/Common'; -import './style.css'; - -const formatDateTime = (value) => ( - value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--' -); - -const DesktopClientDownload = () => { - const [releases, setReleases] = useState({ win32: null, darwin: null }); - const [loading, setLoading] = useState(true); - - const loadLatest = async () => { - setLoading(true); - try { - const [windowsResult, macResult] = await Promise.allSettled([ - getLatestDesktopRelease('', 'win32', 'x64'), - getLatestDesktopRelease('', 'darwin', 'arm64'), - ]); - setReleases({ - win32: windowsResult.status === 'fulfilled' ? windowsResult.value?.data || null : null, - darwin: macResult.status === 'fulfilled' ? macResult.value?.data || null : null, - }); - } catch { - setReleases({ win32: null, darwin: null }); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadLatest(); - }, []); - - return ( - - - - - )} - > -
- {loading ? ( - - ) : releases.win32 || releases.darwin ? ( -
- {[ - { key: 'win32', label: 'Windows', icon: , release: releases.win32 }, - { key: 'darwin', label: 'macOS', icon: , release: releases.darwin }, - ].map((item) => ( -
-
- {item.icon} -
- {item.label} {item.release ? `v${item.release.version}` : '暂未发布'} - - {item.release - ? `发布于 ${formatDateTime(item.release.publishedAt || item.release.createdAt)} · ${item.release.architecture || '--'}` - : `尚无可下载的 ${item.label} 安装包`} - -
- -
- {item.release?.releaseNotes ? ( -
- 更新说明 -

{item.release.releaseNotes}

-
- ) : null} -
- ))} -
- ) : ( - - )} -
-
-
- ); -}; - -export default DesktopClientDownload; diff --git a/frontend/src/pages/DesktopClientDownload/style.css b/frontend/src/pages/DesktopClientDownload/style.css deleted file mode 100644 index 889d1be..0000000 --- a/frontend/src/pages/DesktopClientDownload/style.css +++ /dev/null @@ -1,112 +0,0 @@ -.desktop-client-download-actions { - display: flex; - gap: var(--sycm-space-xs); -} - -.desktop-client-download-body { - min-width: 0; - padding: var(--sycm-module-padding); -} - -.desktop-client-download-platforms { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--sycm-page-section-gap); -} - -.desktop-client-download-card { - min-width: 0; - padding: var(--sycm-space-md); - border: 1px solid var(--sycm-border-light); - border-radius: var(--sycm-radius-md); - background: var(--sycm-bg-container); -} - -.desktop-client-download-summary { - display: flex; - align-items: center; - gap: var(--sycm-space-md); -} - -.desktop-client-download-summary__icon { - display: inline-flex; - width: 40px; - height: 40px; - flex: 0 0 40px; - align-items: center; - justify-content: center; - border: 1px solid var(--sycm-border-base); - border-radius: var(--sycm-radius-lg); - background: var(--sycm-bg-subtle); - color: var(--sycm-text-secondary); - font-size: 18px; -} - -.desktop-client-download-summary__copy { - display: grid; - min-width: 0; - flex: 1; - gap: 4px; -} - -.desktop-client-download-summary > .ant-btn { - flex: 0 0 auto; -} - -.desktop-client-download-summary__copy strong { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); - font-variant-numeric: tabular-nums; -} - -.desktop-client-download-summary__copy span { - overflow: hidden; - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); - text-overflow: ellipsis; - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - -.desktop-client-download-notes { - display: grid; - gap: var(--sycm-space-sm); - margin-top: var(--sycm-module-header-gap); - padding: var(--sycm-space-md); - border: 1px solid var(--sycm-border-light); - border-radius: var(--sycm-radius-lg); - background: var(--sycm-bg-page-soft); -} - -.desktop-client-download-notes__label { - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); -} - -.desktop-client-download-notes p { - margin: 0; - color: var(--sycm-text-primary); - font-size: var(--sycm-font-size-body); - line-height: 22px; - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -@media (max-width: 1000px) { - .desktop-client-download-platforms { - grid-template-columns: minmax(0, 1fr); - } -} - -@media (max-width: 620px) { - .desktop-client-download-summary { - align-items: flex-start; - flex-wrap: wrap; - } - - .desktop-client-download-summary > .ant-btn { - width: 100%; - } -} diff --git a/frontend/src/pages/DesktopClientReleases/index.jsx b/frontend/src/pages/DesktopClientReleases/index.jsx deleted file mode 100644 index 2f15678..0000000 --- a/frontend/src/pages/DesktopClientReleases/index.jsx +++ /dev/null @@ -1,261 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { - AppleOutlined, - CloudUploadOutlined, - DownloadOutlined, - ReloadOutlined, - WindowsOutlined, -} from '@ant-design/icons'; -import { - Button, - Input, - Progress, - Typography, - Upload, - message, -} from 'antd'; -import { - getDesktopReleases, - publishDesktopRelease, -} from '../../api/desktopClient'; -import { SycmPage } from '../../components/Common'; -import './style.css'; - -const { Text, Title } = Typography; -const { TextArea } = Input; -const { Dragger } = Upload; - -const formatFileSize = (value) => { - const bytes = Number(value || 0); - if (!bytes) return '--'; - return `${(bytes / 1024 / 1024).toFixed(1)} MB`; -}; - -const formatDateTime = (value) => ( - value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--' -); - -const inferVersion = (fileName) => ( - String(fileName || '').match(/pdd-auto-sale-(\d+\.\d+\.\d+)(?:-[^.]+)?\.(?:exe|dmg)$/i)?.[1] || '' -); - -const inferInstallerMeta = (fileName) => { - const normalized = String(fileName || '').toLowerCase(); - const platform = normalized.endsWith('.dmg') ? 'darwin' : 'win32'; - const architecture = normalized.match(/[-_.](arm64|x64|universal)(?=[-_.]|$)/)?.[1] - || (platform === 'darwin' ? 'arm64' : 'x64'); - return { - platform, - architecture, - label: platform === 'darwin' ? 'macOS' : 'Windows', - }; -}; - -const platformLabel = (platform, architecture) => ( - `${platform === 'darwin' ? 'macOS' : 'Windows'} · ${architecture || (platform === 'darwin' ? 'arm64' : 'x64')}` -); - -const DesktopClientReleases = () => { - const [releases, setReleases] = useState([]); - const [loading, setLoading] = useState(true); - const [installer, setInstaller] = useState(null); - const [version, setVersion] = useState(''); - const [releaseNotes, setReleaseNotes] = useState(''); - const [publishing, setPublishing] = useState(false); - const [uploadProgress, setUploadProgress] = useState(0); - - const latestWindows = releases.find((release) => release.platform === 'win32' && release.isLatest) || null; - const latestMac = releases.find((release) => release.platform === 'darwin' && release.isLatest) || null; - const installerMeta = useMemo(() => inferInstallerMeta(installer?.name), [installer]); - - const loadReleases = async () => { - setLoading(true); - try { - const response = await getDesktopReleases(); - setReleases(response.data || []); - } catch (error) { - message.error(error.response?.data?.error || error.message || '发布记录加载失败'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadReleases(); - }, []); - - const fileList = useMemo(() => ( - installer ? [{ uid: installer.uid || installer.name, name: installer.name, status: 'done', originFileObj: installer }] : [] - ), [installer]); - - const handleFile = (file) => { - if (!/\.(exe|dmg)$/i.test(String(file.name || ''))) { - message.warning('请选择 Windows(.exe)或 macOS(.dmg)安装包'); - return Upload.LIST_IGNORE; - } - setInstaller(file); - const inferredVersion = inferVersion(file.name); - if (inferredVersion) setVersion(inferredVersion); - return false; - }; - - const handlePublish = async () => { - if (!installer) { - message.warning('请先选择桌面客户端安装包'); - return; - } - if (!/^\d+\.\d+\.\d+$/.test(version.trim())) { - message.warning('版本号格式必须为 x.y.z,例如 0.0.9'); - return; - } - - setPublishing(true); - setUploadProgress(0); - try { - const response = await publishDesktopRelease({ - version: version.trim(), - releaseNotes: releaseNotes.trim(), - installer, - platform: installerMeta.platform, - architecture: installerMeta.architecture, - onUploadProgress: (event) => { - if (event.total) setUploadProgress(Math.round((event.loaded / event.total) * 100)); - }, - }); - message.success(response.message || `桌面客户端 v${version.trim()} 已发布`); - setInstaller(null); - setVersion(''); - setReleaseNotes(''); - setUploadProgress(100); - await loadReleases(); - } catch (error) { - message.error(error.response?.data?.error || error.message || '桌面客户端发布失败'); - } finally { - setPublishing(false); - } - }; - - return ( - -
-
- 客户端版本 - Windows 与 macOS 最新安装包 -
-
- -
- {[ - { key: 'win32', label: 'Windows', icon: , release: latestWindows }, - { key: 'darwin', label: 'macOS', icon: , release: latestMac }, - ].map((item) => ( -
-
- {item.icon} -
- 最新安装包 - {item.label} -
-
- - {item.release ? `v${item.release.version}` : '暂未发布'} - -

{item.release?.releaseNotes || `尚无可下载的 ${item.label} 安装包`}

-
- {item.release ? platformLabel(item.release.platform, item.release.architecture) : item.label} - {formatFileSize(item.release?.fileSize)} - {formatDateTime(item.release?.publishedAt)} -
- -
- ))} -
- -
-
- -
- 发布最新安装包 - 上传后自动替换对应平台的下载入口 -
-
-
- { - setInstaller(null); - return true; - }} - disabled={publishing} - > -

-

拖入 Windows 或 macOS 安装包

-

支持 .exe、.dmg,最大 300 MB

-
- -
- - -